Skip to content

Feat 21 Two Factor Authentication#89

Merged
dennis-zyska merged 83 commits intodevfrom
feat-21-2factor_authentication
Apr 7, 2026
Merged

Feat 21 Two Factor Authentication#89
dennis-zyska merged 83 commits intodevfrom
feat-21-2factor_authentication

Conversation

@pdji1602003
Copy link
Copy Markdown
Collaborator

@pdji1602003 pdji1602003 commented Feb 15, 2026

Main Description

This merge request introduces 2fa implementation and third-party login methods.

New User Features

  1. User can go to user menu > Configure Authentication to configure 2fa and enhance account security.
  2. CARE offers one time token via email and TOTP for 2fa.
  3. If user activates both 2fa methods (email & TOTP), user will be directed to a select page after login.
  4. CARE offers three extra third-party login flows: orcid, LDAP, and SAML.
  5. Admin can enable these login flows in the Setting dashboard.
  6. Admin can enforce 2fa in the Setting dashboard.
  7. Terms description about email processing was updated as we now use email to send the one time code.

Note

  1. SAML login flow requires testing.

Known Limitations

  1. Sever needs to be manually restarted if the admin changes any setting about third-login methods (orcid, LDAP, and SAML)
  2. In the 2fa enforcement flow, everyone is impacted, including admins. Consider admin exemption in the 2fa enforcement flow.
  3. Old user data is cleared now via page reload. A data clearing mechanism is needed.
  4. There is no page where user can provide their email.

@pdji1602003 pdji1602003 self-assigned this Feb 16, 2026
@pdji1602003 pdji1602003 linked an issue Feb 16, 2026 that may be closed by this pull request
@pdji1602003
Copy link
Copy Markdown
Collaborator Author

I also have a general question because I haven't seen something like limiting the amount to tries? Like if someone tries to brute force all 6 digits possibilities, is there something stopping them?

You are right! I have implemented the rate limit to prevent this from happening.

Copy link
Copy Markdown
Collaborator

@dennis-zyska dennis-zyska left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice implementation, the code is already in a really good shape, but there are some smaller issues, like a missing docstring. It could also be rearranged a bit to make the code more understandable.

const EMAIL_2FA_RESEND_COOLDOWN_MS = 60 * 1000;
const MAX_2FA_VERIFY_ATTEMPTS = 5;

function getEmailOtpCooldownInfo(pending) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are a few functions without a docstring.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved it in commit 1e959d5.

return res.status(500).json({ message: "Session error during 2FA." });
}

return res.status(429).json({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also add here RetryAfter information? (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After) Or is it final? If it is final, the 429 error would not apply, and I think it should be possible again after some time, right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add RetryAfter info in commit 0b9feb5.

{ where: { id: userRecord.id } }
);

await server.sendMail(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use the email template feature we already integrated. Please have a look here:

const emailContent = await getEmailContent(

(We don't want to save the mail in the source code, it should be in the database and as file on disk for fallback)

But you can also ask @mohammadsherif0

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved it in commit a4c1533.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mohammadsherif0

I check the template related feature and there are a number of things that feel confusing (but maybe I just don't understand this feature well enough):

  1. the return value of getEmailContent contains fallback.subject, which means this feature couldn't work without fallback. In this sense, it's not a fallback mechanism, it's a dependence, right? I wonder why we didn't save the subject information into the DB, but depend on this two-step procedure of retrieving information required for an email.
    return { subject: fallback.subject, body: resolvedHtml, isHtml: true };
  2. When I tried to create a new system email, I needed to check several files, modify several places such as templateResolver, create migration, and create fallback files. The procedure feels a bit fragmented. Is it intended like this?
  3. It’s related to the previous point. if the new system email requires new kinds of placeholder, we need to go through migration, check frontend (to if the frontend component supports this placeholder) and may need to expand the backed logic. It feels a bit complicated from my perspective.
  4. The placeholder table has the column, type, but it does not mean the type of the placeholder, but the type of template, which makes me feel like it’s a foreignKey, while in reality, it isn’t. The naming is a bit confusing for two reasons:
    1. If it’s not a foreign key, then there is no restraint upon it. It could be a type that does not exist in Template table and the database wouldn’t give us warning.
    2. I would actually assume that the type of placeholder takes value such as ‘text’ (what placeholderType does/holds now)and we have another column called, say, templateType.
  5. On the frontend, the Publish Template feature shows a list of templates published by others, excluding our own public templates. I know there is description explaining this, but I kind of feel it’s not intuitive if we exclude public templates from our own.

* @param {string} [options.redirectPath='/dashboard'] - The path to redirect to if mode is 'redirect'.
* @returns {Promise<void>}
*/
async function finalizeLogin(req, res, user, options = { mode: 'json', redirectPath: '/dashboard' }) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we open a study URL before logging in to the account? Are we still redirected to that study URL after successful login

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your observation was right. There wasn't the redirect mechanism in place previously.
Resolved it in commit 8bb7777.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a huge file, I think we can create a subfolder ./backend/webserver/routes/auth/... and create subfiles for examples ./auth/totp.js for each method.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in commit 1e959d5.

Comment on lines +27 to +89
const SAML_ATTRIBUTE_KEYS = {
email: [
"email",
"mail",
"urn:oid:1.2.840.113549.1.9.1",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
],
firstName: [
"firstName",
"givenName",
"urn:oid:2.5.4.42",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
],
lastName: [
"lastName",
"sn",
"surname",
"familyName",
"urn:oid:2.5.4.4",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
],
};

function getFirstPresentValue(source, keys = []) {
for (const key of keys) {
const value = source?.[key];
if (Array.isArray(value) && value.length > 0 && value[0]) {
return value[0];
}
if (value) return value;
}
return null;
}

function getProvisionedNameParts({ firstName, lastName, email, fullName, fallbackFirstName, fallbackLastName }) {
const normalizedFirstName = Array.isArray(firstName) ? firstName[0] : firstName;
const normalizedLastName = Array.isArray(lastName) ? lastName[0] : lastName;
const toDisplayNamePart = (value, fallback) => {
if (!value) return fallback;
return value.charAt(0).toUpperCase() + value.slice(1);
};

if (normalizedFirstName && normalizedLastName) {
return { firstName: normalizedFirstName, lastName: normalizedLastName };
}

if (email) {
const localPart = (email || "").split("@")[0] || "";
const [rawFirstName, ...rest] = localPart.split(".").filter(Boolean);
const rawLastName = rest.join(".");
return {
firstName: normalizedFirstName || toDisplayNamePart(rawFirstName, fallbackFirstName),
lastName: normalizedLastName || toDisplayNamePart(rawLastName, fallbackLastName),
};
}

const parts = (fullName || "").trim().split(/\s+/).filter(Boolean);
return {
firstName: normalizedFirstName || toDisplayNamePart(parts[0], fallbackFirstName),
lastName: normalizedLastName || toDisplayNamePart(parts.slice(1).join(" "), fallbackLastName),
};
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstrings are missing, can we move this block to some files like ../utils/auth.js , this would be much cleaner

Copy link
Copy Markdown
Collaborator Author

@pdji1602003 pdji1602003 Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in commit 7cda274.

await this.#setupLocalStrategy();
await this.#setupOrcidStrategy();
await this.#setupLdapStrategy();
await this.#setupSamlStrategy();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can move those functions as well into an extra file for the loginManagement (like a class Login.js that holds everything that is important regarding the Login). This file is also already really big and it would make it easier to get

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in commit 7cda274.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To add more context, I organized the strategies as functions rather than a class because this layer is mostly stateless startup logic and only gets initialized once when the server starts up. But if you think this is not a good idea, please let me know.

path: "/2fa/verify/email",
name: "2fa-verify-email",
component: () => import("@/auth/TwoFactorVerifyEmail.vue"),
meta: { requiresAuth: false }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is with the additional meta information like hideTopbar and checkLogin, also in the other new routes?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in commit 62ed88a.

@pdji1602003 pdji1602003 requested a review from dennis-zyska April 5, 2026 19:47
@dennis-zyska dennis-zyska merged commit ffcd2f3 into dev Apr 7, 2026
3 checks passed
@dennis-zyska dennis-zyska deleted the feat-21-2factor_authentication branch April 7, 2026 13:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Two-factor Authentication

3 participants