Skip to content

Feat 21 Two Factor Authentication#89

Open
pdji1602003 wants to merge 81 commits intodevfrom
feat-21-2factor_authentication
Open

Feat 21 Two Factor Authentication#89
pdji1602003 wants to merge 81 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
rate limitation is not implemented on backend, so remove the frontend part
Copy link
Copy Markdown
Collaborator

@melolw melolw left a comment

Choose a reason for hiding this comment

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

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?

Overall the code is great! I learned a lot along the way, I have limited knowledge about authentification so most of my questions are things I saw in theory only. I also think it's a great addition to the software, 2FA is such an important feature 😎

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.changeColumn("user", "email", {
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.

[QUESTION] should this also be in the down function? changing back the column "email" as it was
Also, why are we allowing null for emails?

Copy link
Copy Markdown
Collaborator Author

@pdji1602003 pdji1602003 Mar 27, 2026

Choose a reason for hiding this comment

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

Resolved in commit ee743fc

We are allowing email to be Null now because we added OrcId login method this time, which means users can log in using their orcid account. Orcid users can choose not to share their email with our platform. In this case, we cannot get such information. If we are not allowing email to be null, then we will run into issue when creating user account for orcid users.

},
{
key: "system.auth.orcid.clientSecret",
type: "string",
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.

[QUESTION] i'm not very knowledgable about security, but is this (and a few others in this file) fine to put as a string in the database?

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.

Database encryption will be left with Junaid to handle.

// TOTP for 2FA
totpSecret: DataTypes.STRING,
// External login method identifiers
orcidId: DataTypes.STRING,
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.

[question] should we add unique: true here too?

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.

We usually set the extra rules in the migration files; here we do not need to add unique: true.

*/
exports.generateOTP = function generateOTP() {
// Generate a random 6-digit number (000000 to 999999)
const otp = Math.floor(100000 + Math.random() * 900000).toString();
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.

[suggestion] could we use Node's crypto module? i read here (https://www.boot.dev/blog/javascript/node-js-random-number/) that math.random is not safe and here (https://www.geeksforgeeks.org/node-js/node-js-crypto-randomint-method/) and here (https://www.w3schools.com/nodejs/nodejs_crypto.asp) is a method for generating a random number with crypto

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.

This is a good catch! Yes, since we use Node.js, it's better to use its module. I have updated it in commit 3c25645

if (typeof raw === "string" && raw.length > 0) {
this.methods = raw.split(",").filter((m) => !!m);
}
// Fallback: just support email/totp if nothing present
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.

[question] why email/totp? why not only email?

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.

Users are directed here only when they have two 2FA methods, so the fallback should be email/totp as well; otherwise, they wouldn't be on this page, but TwoFactorVerifyEmail or TwoFactorVerifyTotp.

<button
class="btn btn-primary btn-block"
type="submit"
:disabled="!selectedMethod || isSubmitting"
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.

[suggestion] could we add that to the cancel and radio button too? so that the user can't cancel when they are already submitting

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.

This is a very cautious suggestion, but I am not sure if it is necessary. The user will be directed to the next page as soon as they click on "Continue" button. There is little to no time for them to click “Cancel.”

"A new verification code has been sent to your Email";

// Start cooldown timer (60 seconds)
this.resendCooldown = 60;
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.

[question] what if they reload the page? is the cooldown also tracked somewhere in the backend? could not find it

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 in commit 12d73e9.

@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.

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?

{ 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

* @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

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.

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

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

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?

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