From eb985d37c679ed436b8081f42d1cd0676afd0c81 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Fri, 30 Jan 2026 15:53:06 +0100 Subject: [PATCH 01/58] feat: implement 2fa via email --- .../migrations/20260126144237-extend-user.js | 47 +++ backend/db/models/user.js | 7 + backend/utils/auth.js | 11 + backend/webserver/routes/auth.js | 71 ++++- frontend/src/auth/Login.vue | 17 +- frontend/src/auth/TwoFactorVerifyEmail.vue | 301 ++++++++++++++++++ frontend/src/router.js | 6 + 7 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 backend/db/migrations/20260126144237-extend-user.js create mode 100644 frontend/src/auth/TwoFactorVerifyEmail.vue diff --git a/backend/db/migrations/20260126144237-extend-user.js b/backend/db/migrations/20260126144237-extend-user.js new file mode 100644 index 000000000..787678c3b --- /dev/null +++ b/backend/db/migrations/20260126144237-extend-user.js @@ -0,0 +1,47 @@ +"use strict"; + +const columns = [ + { + name: "twoFactorEnabled", + type: "BOOLEAN", + defaultValue: false, + allowNull: false, + }, + { + name: "twoFactorMethod", + type: "STRING", + allowNull: true, + defaultValue: null, + }, + { + name: "twoFactorOtp", + type: "STRING", + allowNull: true, + defaultValue: null, + }, + { + name: "twoFactorOtpExpiresAt", + type: "DATE", + allowNull: true, + defaultValue: null, + }, +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + for (const column of columns) { + await queryInterface.addColumn("user", column.name, { + type: Sequelize[column.type], + defaultValue: column.defaultValue, + allowNull: column.allowNull, + }); + } + }, + + async down(queryInterface, Sequelize) { + for (const column of columns) { + await queryInterface.removeColumn("user", column.name); + } + }, +}; diff --git a/backend/db/models/user.js b/backend/db/models/user.js index 0806917e5..12ee8e597 100644 --- a/backend/db/models/user.js +++ b/backend/db/models/user.js @@ -555,6 +555,13 @@ module.exports = (sequelize, DataTypes) => { emailVerificationToken: DataTypes.STRING, lastPasswordResetEmailSent: DataTypes.DATE, lastVerificationEmailSent: DataTypes.DATE, + twoFactorEnabled: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + twoFactorMethod: DataTypes.STRING, + twoFactorOtp: DataTypes.STRING, + twoFactorOtpExpiresAt: DataTypes.DATE, }, { sequelize, diff --git a/backend/utils/auth.js b/backend/utils/auth.js index b70387526..72e3ddc7c 100644 --- a/backend/utils/auth.js +++ b/backend/utils/auth.js @@ -127,3 +127,14 @@ exports.decodeToken = function decodeToken(token) { return { isValid: false, expired: false, expiryTime: null }; } } + +/** + * Generate a 6-digit OTP (One-Time Password) + * @returns {string} 6-digit OTP + */ +exports.generateOTP = function generateOTP() { + // Generate a random 6-digit number (000000 to 999999) + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + return otp; +} + diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index fc10d7ac4..a16e08aae 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -7,7 +7,7 @@ * @author Nils Dycke, Dennis Zyska */ const passport = require('passport'); -const { generateToken, decodeToken } = require('../../utils/auth'); +const { generateToken, decodeToken, generateOTP, relevantFields } = require('../../utils/auth'); /** * Route for user management @@ -101,6 +101,75 @@ module.exports = function (server) { }); } + + // Check if 2FA is enabled for this user + if (user.twoFactorEnabled) { + const method = user.twoFactorMethod; + + // Store user data in session for 2FA verification + // Session will be automatically managed by express-session + req.session.twoFactorPending = { + userId: user.id, + userData: user, + method: method + }; + + if(method === "email") { + // User has 2FA enabled with email method + if (!user.email) { + return res.status(400).json({ + message: "Email address is required for 2FA but not found for this user." + }); + } + + try { + // Generate and send OTP automatically + const otp = generateOTP(); + const otpExpiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes expiry + + // Store OTP in user record + await server.db.models['user'].update( + { + twoFactorOtp: otp, + twoFactorOtpExpiresAt: otpExpiresAt + }, + { where: { id: user.id } } + ); + // Send OTP via email + await server.sendMail( + user.email, + "CARE - Two-Factor Authentication Code", + `Hello ${user.userName}, + Your two-factor authentication code is: ${otp} + + This code will expire in 10 minutes. If you didn't request this code, please ignore this email. + + Thanks, + The CARE Team` + ); + + // Save session and return - session cookie will be sent automatically + req.session.save((err) => { + if (err) { + server.logger.error("Failed to save session: " + err); + return res.status(500).json({ message: "Failed to initiate 2FA verification." }); + } + + return res.status(200).json({ + requiresTwoFactor: true, + method: "email", + message: "Authentication code has been sent to your email. Please check." + }); + }); + return; // Exit early to prevent normal login + } catch (error) { + server.logger.error("Failed to initiate 2FA: " + error); + return res.status(500).json({ message: "Failed to initiate 2FA verification." }); + } + } + } + + // No 2FA required, proceed with normal login req.logIn(user, async function (err) { if (err) { return next(err); diff --git a/frontend/src/auth/Login.vue b/frontend/src/auth/Login.vue index 1e8ec773f..80ec97e61 100644 --- a/frontend/src/auth/Login.vue +++ b/frontend/src/auth/Login.vue @@ -300,6 +300,21 @@ export default { } throw response.data.message; } + + // Check if 2FA is required + if (response.status === 200 && response.data.requiresTwoFactor) { + // Redirect to 2FA verification page with method info + await this.$router.push({ + name: "2fa-verify-email", + query: { + method: response.data.method, + redirectedFrom: this.$route.query.redirectedFrom + } + }); + return; + } + + // Normal login flow (no 2FA) await this.$router.push(this.$route.query.redirectedFrom || '/dashboard') }, @@ -371,4 +386,4 @@ input:focus.custom-invalid { border-radius: 0.25rem; } - \ No newline at end of file + diff --git a/frontend/src/auth/TwoFactorVerifyEmail.vue b/frontend/src/auth/TwoFactorVerifyEmail.vue new file mode 100644 index 000000000..314777016 --- /dev/null +++ b/frontend/src/auth/TwoFactorVerifyEmail.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index f8f090162..38ae3061c 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -33,6 +33,12 @@ const routes = [ name: "login", meta: {requireAuth: false, hideTopbar: true, checkLogin: true} }, + { + path: "/2fa/verify/email", + name: "2fa-verify-email", + component: () => import("@/auth/TwoFactorVerifyEmail.vue"), + meta: { requiresAuth: false } + }, { path: "/register", name: "register", From 6405190fdd494a71d57c700459dbca576b789b90 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Fri, 30 Jan 2026 17:40:51 +0100 Subject: [PATCH 02/58] feat: verify OTP and complete login --- backend/webserver/routes/auth.js | 87 ++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index a16e08aae..02fc97146 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -135,6 +135,8 @@ module.exports = function (server) { }, { where: { id: user.id } } ); + + // TODO: Needs to format the email content // Send OTP via email await server.sendMail( user.email, @@ -588,4 +590,89 @@ The CARE Team` return res.status(500).json({message: "Internal server error"}); } }); + /** + * Verify OTP and complete login + * Uses session to track 2FA state + */ + server.app.post('/auth/2fa/verify', async function (req, res) { + const { otp } = req.body; + + if (!otp) { + return res.status(400).json({ message: "OTP is required." }); + } + + // Check if session has 2FA pending state + if (!req.session || !req.session.twoFactorPending) { + return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); + } + + try { + const { userId, userData } = req.session.twoFactorPending; + // Get user details + const user = await server.db.models['user'].findOne({ + where: { id: userId } + }); + + + if (!user) { + // Clear invalid session state + delete req.session.twoFactorPending; + return res.status(400).json({ message: "User not found." }); + } + + // Verify OTP + if (!user.twoFactorOtp || user.twoFactorOtp !== otp) { + return res.status(401).json({ message: "Invalid OTP code." }); + } + + // Check if OTP has expired + if (!user.twoFactorOtpExpiresAt || new Date() > new Date(user.twoFactorOtpExpiresAt)) { + await server.db.models['user'].update( + { twoFactorOtp: null, twoFactorOtpExpiresAt: null }, + { where: { id: user.id } } + ); + // Clear session state + delete req.session.twoFactorPending; + return res.status(400).json({ message: "OTP has expired. Please request a new one." }); + } + + // OTP is valid - clear OTP and session state, then complete login + await server.db.models['user'].update( + { twoFactorOtp: null, twoFactorOtpExpiresAt: null }, + { where: { id: user.id } } + ); + + // Clear 2FA pending state from session + delete req.session.twoFactorPending; + + // Complete login + req.logIn(user, async function (err) { + if (err) { + return res.status(500).json({ message: "Failed to complete login." }); + } + + // Save session after login + req.session.save((saveErr) => { + if (saveErr) { + server.logger.error("Failed to save session after login: " + saveErr); + } + }); + + let transaction; + try { + transaction = await server.db.models['user'].sequelize.transaction(); + await server.db.models['user'].registerUserLogin(user.id, {transaction: transaction}); + await transaction.commit(); + } catch (e) { + await transaction.rollback(); + } + + return res.status(200).json({ user: user }); + }); + + } catch (error) { + server.logger.error("Failed to verify OTP: " + error); + return res.status(500).json({ message: "Internal server error" }); + } + }); } From ae71f890c9b680f5252f727587d68bd3c2c50294 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Tue, 3 Feb 2026 14:30:20 +0100 Subject: [PATCH 03/58] chore: reorder lifecycle methods --- frontend/src/basic/navigation/Topbar.vue | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/basic/navigation/Topbar.vue b/frontend/src/basic/navigation/Topbar.vue index 1cebc866f..61639cb9f 100644 --- a/frontend/src/basic/navigation/Topbar.vue +++ b/frontend/src/basic/navigation/Topbar.vue @@ -1,7 +1,7 @@ + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 38ae3061c..255789872 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -39,6 +39,12 @@ const routes = [ component: () => import("@/auth/TwoFactorVerifyEmail.vue"), meta: { requiresAuth: false } }, + { + path: '/2fa/ldap/verify', + name: '2fa-verify-ldap', + component: () => import("@/auth/TwoFactorVerifyLDAP.vue"), + meta: { requiresAuth: false } + }, { path: "/register", name: "register", From 913a345c23c172b8f68a8a3988990901d3680233 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Mon, 9 Feb 2026 16:44:46 +0100 Subject: [PATCH 06/58] feat: extend user table with more login-required columns --- .../migrations/20260126144237-extend-user.js | 44 +++++++++++++++++++ backend/db/models/user.js | 13 ++++++ 2 files changed, 57 insertions(+) diff --git a/backend/db/migrations/20260126144237-extend-user.js b/backend/db/migrations/20260126144237-extend-user.js index fc9bcf3c6..0b90d6fc2 100644 --- a/backend/db/migrations/20260126144237-extend-user.js +++ b/backend/db/migrations/20260126144237-extend-user.js @@ -1,6 +1,7 @@ "use strict"; const columns = [ + // 2FA core flags { name: "twoFactorEnabled", type: "BOOLEAN", @@ -25,12 +26,54 @@ const columns = [ allowNull: true, defaultValue: null, }, + // LDAP support { name: "ldapDomain", type: "STRING", allowNull: true, defaultValue: null, }, + { + name: "ldapUsername", + type: "STRING", + allowNull: true, + defaultValue: null, + }, + // ORCID support + { + name: "orcidId", + type: "STRING", + allowNull: true, + defaultValue: null, + unique: true, + }, + // SAML support + { + name: "samlNameId", + type: "STRING", + allowNull: true, + defaultValue: null, + unique: true, + }, + // Multi-method 2FA configuration + { + name: "twoFactorMethods", + type: "JSON", + allowNull: false, + defaultValue: [], + }, + { + name: "totpEnabled", + type: "BOOLEAN", + allowNull: false, + defaultValue: false, + }, + { + name: "totpSecret", + type: "STRING", + allowNull: true, + defaultValue: null, + }, ]; /** @type {import('sequelize-cli').Migration} */ @@ -41,6 +84,7 @@ module.exports = { type: Sequelize[column.type], defaultValue: column.defaultValue, allowNull: column.allowNull, + ...(column.unique ? { unique: true } : {}), }); } }, diff --git a/backend/db/models/user.js b/backend/db/models/user.js index 4473cb507..9cb2c3f94 100644 --- a/backend/db/models/user.js +++ b/backend/db/models/user.js @@ -562,7 +562,20 @@ module.exports = (sequelize, DataTypes) => { twoFactorMethod: DataTypes.STRING, twoFactorOtp: DataTypes.STRING, twoFactorOtpExpiresAt: DataTypes.DATE, + // 2FA multi-method support + twoFactorMethods: { + type: DataTypes.JSON, + defaultValue: [], + }, + totpSecret: DataTypes.STRING, + totpEnabled: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + orcidId: DataTypes.STRING, ldapDomain: DataTypes.STRING, + ldapUsername: DataTypes.STRING, + samlNameId: DataTypes.STRING, }, { sequelize, From eae28f379a64ae3e82e3aa448e7fdbb80e9eb404 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Mon, 9 Feb 2026 17:22:54 +0100 Subject: [PATCH 07/58] refactor: remove redundant columns --- .../migrations/20260126144237-extend-user.js | 20 +------------------ backend/db/models/user.js | 14 ++++--------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/backend/db/migrations/20260126144237-extend-user.js b/backend/db/migrations/20260126144237-extend-user.js index 0b90d6fc2..cb89be6de 100644 --- a/backend/db/migrations/20260126144237-extend-user.js +++ b/backend/db/migrations/20260126144237-extend-user.js @@ -1,19 +1,7 @@ "use strict"; const columns = [ - // 2FA core flags - { - name: "twoFactorEnabled", - type: "BOOLEAN", - defaultValue: false, - allowNull: false, - }, - { - name: "twoFactorMethod", - type: "STRING", - allowNull: true, - defaultValue: null, - }, + // Email OTP for 2FA { name: "twoFactorOtp", type: "STRING", @@ -62,12 +50,6 @@ const columns = [ allowNull: false, defaultValue: [], }, - { - name: "totpEnabled", - type: "BOOLEAN", - allowNull: false, - defaultValue: false, - }, { name: "totpSecret", type: "STRING", diff --git a/backend/db/models/user.js b/backend/db/models/user.js index 9cb2c3f94..f88ff0532 100644 --- a/backend/db/models/user.js +++ b/backend/db/models/user.js @@ -555,23 +555,17 @@ module.exports = (sequelize, DataTypes) => { emailVerificationToken: DataTypes.STRING, lastPasswordResetEmailSent: DataTypes.DATE, lastVerificationEmailSent: DataTypes.DATE, - twoFactorEnabled: { - type: DataTypes.BOOLEAN, - defaultValue: false - }, - twoFactorMethod: DataTypes.STRING, + // Email OTP for 2FA twoFactorOtp: DataTypes.STRING, twoFactorOtpExpiresAt: DataTypes.DATE, - // 2FA multi-method support + // Multi-method 2FA configuration twoFactorMethods: { type: DataTypes.JSON, defaultValue: [], }, + // TOTP for 2FA totpSecret: DataTypes.STRING, - totpEnabled: { - type: DataTypes.BOOLEAN, - defaultValue: false, - }, + // External login method identifiers orcidId: DataTypes.STRING, ldapDomain: DataTypes.STRING, ldapUsername: DataTypes.STRING, From 6f3fb1d49a3fe1f64c1a35daf0cc50dc88af131f Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Mon, 9 Feb 2026 20:49:36 +0100 Subject: [PATCH 08/58] refactor: update 2FA endpoints and remove previous LDAP and ORCID implementation --- backend/webserver/routes/auth.js | 399 +++++++++++++++++++++---------- 1 file changed, 276 insertions(+), 123 deletions(-) diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index bcf07e52e..455a950e5 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -76,6 +76,151 @@ module.exports = function (server) { return { allowed: true }; } + /** + * Get enabled 2FA methods from a user record. + * @param {Object} user - The user object + * @returns {string[]} array of enabled 2FA methods + */ + function getTwoFactorMethods(user) { + if (!user) { + return []; + } + + if (Array.isArray(user.twoFactorMethods)) { + return user.twoFactorMethods.filter((m) => !!m); + } + + return []; + } + + /** + * Shared helper to decide whether 2FA is required for a login and, if so, + * to initiate the appropriate 2FA flow (email / TOTP). + * + * Returns true if a 2FA response has been sent and normal login should stop. + * + * @param {Object} req + * @param {Object} res + * @param {Object} user - plain user object returned by passport (relevantFields) + * @returns {Promise} + */ + async function initiateTwoFactorIfRequired(req, res, user) { + // Load full user record to get latest 2FA configuration + const userRecord = await server.db.models['user'].findOne({ + where: { id: user.id }, + }); + + if (!userRecord) { + return false; + } + + const methods = getTwoFactorMethods(userRecord); + + // No 2FA configured -> normal login + if (!methods || methods.length === 0) { + return false; + } + + // For now, pick the first configured method. + // Later, we can extend this to support user selection. + const primaryMethod = methods[0]; + + // Store 2FA pending state in session + req.session.twoFactorPending = { + userId: userRecord.id, + userData: user, + method: primaryMethod, + methods: methods + }; + + // Email-based 2FA + if (primaryMethod === "email") { + const email = user.email || userRecord.email; + + if (!email) { + return res.status(400).json({ + message: "Email address is required for 2FA but not found for this user." + }); + } + + try { + // Generate and send OTP automatically + const otp = generateOTP(); + const otpExpiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes expiry + + // Store OTP in user record + await server.db.models['user'].update( + { + twoFactorOtp: otp, + twoFactorOtpExpiresAt: otpExpiresAt + }, + { where: { id: userRecord.id } } + ); + + // Send OTP via email + await server.sendMail( + email, + "CARE - Two-Factor Authentication Code", + `Hello ${user.userName}, +Your two-factor authentication code is: ${otp} + +This code will expire in 10 minutes. If you didn't request this code, please ignore this email. + +Thanks, +The CARE Team` + ); + + // Save session and return - session cookie will be sent automatically + req.session.save((err) => { + if (err) { + server.logger.error("Failed to save session: " + err); + return res.status(500).json({ message: "Failed to initiate 2FA verification." }); + } + + return res.status(200).json({ + requiresTwoFactor: true, + method: "email", + message: "Authentication code has been sent to your email. Please check." + }); + }); + return true; + } catch (error) { + server.logger.error("Failed to initiate 2FA: " + error); + res.status(500).json({ message: "Failed to initiate 2FA verification." }); + return true; + } + } + + // TOTP-based 2FA + if (primaryMethod === "totp") { + req.session.save((err) => { + if (err) { + server.logger.error("Failed to save session: " + err); + return res.status(500).json({ message: "Failed to initiate 2FA verification." }); + } + + return res.status(200).json({ + requiresTwoFactor: true, + method: "totp", + message: "Two-factor authentication is required." + }); + }); + return true; + } + + // Unsupported method – fall back to normal login + return false; + } + + function ensureAuthenticated(req, res, next) { + if (req.isAuthenticated()) { + return next(); + } + res.status(401).json({ + message: 'Authentication required' + }); + } + /** * Login Procedure */ @@ -102,89 +247,11 @@ module.exports = function (server) { } - // Check if 2FA is enabled for this user - if (user.twoFactorEnabled) { - const method = user.twoFactorMethod; - - // Store user data in session for 2FA verification - // Session will be automatically managed by express-session - req.session.twoFactorPending = { - userId: user.id, - userData: user, - method: method - }; - - if(method === "email") { - // User has 2FA enabled with email method - if (!user.email) { - return res.status(400).json({ - message: "Email address is required for 2FA but not found for this user." - }); - } - - try { - // Generate and send OTP automatically - const otp = generateOTP(); - const otpExpiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes expiry - - // Store OTP in user record - await server.db.models['user'].update( - { - twoFactorOtp: otp, - twoFactorOtpExpiresAt: otpExpiresAt - }, - { where: { id: user.id } } - ); - - // TODO: Needs to format the email content - // Send OTP via email - await server.sendMail( - user.email, - "CARE - Two-Factor Authentication Code", - `Hello ${user.userName}, - Your two-factor authentication code is: ${otp} - - This code will expire in 10 minutes. If you didn't request this code, please ignore this email. - - Thanks, - The CARE Team` - ); - - // Save session and return - session cookie will be sent automatically - req.session.save((err) => { - if (err) { - server.logger.error("Failed to save session: " + err); - return res.status(500).json({ message: "Failed to initiate 2FA verification." }); - } - - return res.status(200).json({ - requiresTwoFactor: true, - method: "email", - message: "Authentication code has been sent to your email. Please check." - }); - }); - return; // Exit early to prevent normal login - } catch (error) { - server.logger.error("Failed to initiate 2FA: " + error); - return res.status(500).json({ message: "Failed to initiate 2FA verification." }); - } - } - - if(method === "ldapauth") { - req.session.save((err) => { - if (err) { - server.logger.error("Failed to save session: " + err); - return res.status(500).json({ message: "Failed to initiate 2FA verification." }); - } - - return res.status(200).json({ - requiresTwoFactor: true, - method: "ldapauth", - message: "success" - }); - }); - return; // Exit early to prevent normal login - } + // Check if 2FA is enabled for this user (multi-method aware) + const twoFactorHandled = await initiateTwoFactorIfRequired(req, res, user); + if (twoFactorHandled) { + // 2FA response has been sent; stop normal login flow + return; } // No 2FA required, proceed with normal login @@ -606,6 +673,76 @@ The CARE Team` return res.status(500).json({message: "Internal server error"}); } }); + + /** + * Request OTP again for 2FA verification + * Called after password verification when user has 2FA enabled + * Uses session to track 2FA state + */ + server.app.post('/auth/2fa/resend-otp', async function (req, res) { + // Check if session has 2FA pending state + if (!req.session || !req.session.twoFactorPending) { + return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); + } + + try { + const { userId } = req.session.twoFactorPending; + + // Get user details + const user = await server.db.models['user'].findOne({ + where: { id: userId } + }); + + const methods = getTwoFactorMethods(user); + + if (!user || !methods.includes('email')) { + // Clear invalid session state + delete req.session.twoFactorPending; + return res.status(400).json({ message: "2FA is not enabled for this user." }); + } + + if (!user.email) { + return res.status(400).json({ message: "User email not found. Cannot send OTP." }); + } + + // Generate 6-digit OTP + const otp = generateOTP(); + const otpExpiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes expiry + + // Store OTP in user record + await server.db.models['user'].update( + { + twoFactorOtp: otp, + twoFactorOtpExpiresAt: otpExpiresAt + }, + { where: { id: user.id } } + ); + + // Send OTP via email + await server.sendMail( + user.email, + "CARE - Two-Factor Authentication Code", + `Hello ${user.userName}, + + Your two-factor authentication code is: ${otp} + + This code will expire in 10 minutes. If you didn't request this code, please ignore this email. + + Thanks, + The CARE Team` + ); + + return res.status(200).json({ + message: "OTP has been sent to your email address.", + expiresIn: 10 // minutes + }); + + } catch (error) { + server.logger.error("Failed to request OTP: " + error); + return res.status(500).json({ message: "Internal server error" }); + } + }); + /** * Verify OTP and complete login * Uses session to track 2FA state @@ -703,16 +840,29 @@ The CARE Team` try { const user = await server.db.models['user'].findOne({ where: { id: req.user.id }, - attributes: ['twoFactorEnabled', 'twoFactorMethod', 'email'] + attributes: [ + 'twoFactorMethods', + 'totpSecret', + 'email', + 'orcidId', + 'ldapDomain' + ] }); if (!user) { return res.status(404).json({ message: "User not found." }); } + const methods = getTwoFactorMethods(user); + const hasTotp = methods.includes('totp') && !!user.totpSecret; + return res.status(200).json({ - twoFactorEnabled: user.twoFactorEnabled || false, - twoFactorMethod: user.twoFactorMethod || null, + twoFactorMethods: methods, + hasEmail: methods.includes('email'), + hasTotp: hasTotp, + email: user.email || null, + orcidId: user.orcidId || null, + ldapDomain: user.ldapDomain || null, }); } catch (error) { @@ -730,9 +880,10 @@ The CARE Team` } const { method } = req.body; - - if (!method || !['email', 'orgId', 'ldapauth'].includes(method)) { - return res.status(400).json({ message: "Valid 2FA method is required (email, orgId, or Idapauth)." }); + + // New 2FA methods: email and totp + if (!method || !['email', 'totp'].includes(method)) { + return res.status(400).json({ message: "Valid 2FA method is required (email or totp)." }); } try { @@ -747,20 +898,26 @@ The CARE Team` if (method === 'email' && !user.email) { return res.status(400).json({ message: "Email address is required to enable email 2FA." }); } - + + // Compute updated list of 2FA methods + const currentMethods = Array.isArray(user.twoFactorMethods) ? user.twoFactorMethods.slice() : []; + if (!currentMethods.includes(method)) { + currentMethods.push(method); + } + + const updateData = { + twoFactorMethods: currentMethods, + }; + // Enable 2FA await server.db.models['user'].update( - { - twoFactorEnabled: true, - twoFactorMethod: method - }, + updateData, { where: { id: user.id } } ); return res.status(200).json({ message: `2FA has been enabled with ${method} method.`, - twoFactorEnabled: true, - twoFactorMethod: method + twoFactorMethods: currentMethods, }); } catch (error) { @@ -789,17 +946,17 @@ The CARE Team` // Disable 2FA and clear related fields await server.db.models['user'].update( { - twoFactorEnabled: false, - twoFactorMethod: null, twoFactorOtp: null, - twoFactorOtpExpiresAt: null + twoFactorOtpExpiresAt: null, + twoFactorMethods: [], + totpSecret: null, }, { where: { id: user.id } } ); return res.status(200).json({ message: "2FA has been disabled.", - twoFactorEnabled: false + twoFactorMethods: [] }); } catch (error) { @@ -809,30 +966,26 @@ The CARE Team` }); /** - * LDAP 2FA verification + * Initiate ORCID linking + * Passport handles: redirect to ORCID, state generation, etc. */ - server.app.post('/auth/2fa/ldap/verify', - passport.authenticate('ldap-2fa', { session: false }), - async function(req, res) { - const {user} = req; - - req.logIn(user, async function (err) { - if (err) { - return next(err); - } - - let transaction; - try { - transaction = await server.db.models['user'].sequelize.transaction(); - - await server.db.models['user'].registerUserLogin(user.id, {transaction: transaction}); - await transaction.commit(); - } catch (e) { - await transaction.rollback(); - } - - res.status(200).send({user: user}); - }); + server.app.get('/auth/orcid/link', + ensureAuthenticated, + passport.authenticate('orcid-link') + ); + + /** + * ORCID link callback + * Passport handles: code exchange, token verification, etc. + */ + server.app.get('/auth/orcid/link/callback', + passport.authenticate('orcid-link', { + session: false, + failureRedirect: '/dashboard?error=orcid-link-failed' + }), + async (req, res) => { + // req.user contains { userId, orcidId, action } + res.redirect('/dashboard?orcid-linked=success'); } ); } From 1d22ae194316726ff5758154d02edb8a09d3580c Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Tue, 10 Feb 2026 21:05:39 +0100 Subject: [PATCH 09/58] feat: implement orcid, totp, and saml --- .env | 20 +- backend/package.json | 6 +- backend/utils/auth.js | 14 + backend/webserver/Server.js | 222 +++++++-- backend/webserver/routes/auth.js | 501 +++++++++++++++++---- frontend/src/auth/TwoFactorVerifyEmail.vue | 2 +- 6 files changed, 637 insertions(+), 128 deletions(-) diff --git a/.env b/.env index 994466470..81c719b4b 100644 --- a/.env +++ b/.env @@ -63,4 +63,22 @@ PG_STATS_MIN_AGE_MS=1000 # Intended maximum number of long‑running active queries to log. # NOTE: Current SQL has a hard LIMIT 10; change only matters if code is updated # to parameterize the LIMIT. -PG_STATS_TOP_N=10 \ No newline at end of file +PG_STATS_TOP_N=10 + +# NOTE: For testing only. +ORCID_CLIENT_ID=APP-F9HOSRPWAZTZKKRJ +ORCID_CLIENT_SECRET=0fbb6db5-7164-49d6-8bae-0203adf8691d +ORCID_LINK_CALLBACK_URL=http://localhost:3000/auth/orcid/link/callback +ORCID_LOGIN_CALLBACK_URL=http://localhost:3000/auth/2fa/orcid/callback + +LDAP_SERVER_URL=ldap://localhost:389 +LDAP_BIND_DN='cn=admin,dc=example,dc=org' +LDAP_BIND_CREDENTIALS='admin' +LDAP_SEARCH_BASE='ou=users,dc=example,dc=org' +LDAP_SEARCH_FILTER='(uid={{username}})' + +# TODO: To be modified later +SAML_ENTRY_POINT=test +SAML_ISSUER=test +SAML_CALLBACK_URL=test +SAML_CERT=test \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 8e3645b03..79f1954d9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -39,11 +39,14 @@ "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", "passport-orcid": "^0.0.4", + "passport-saml": "^3.2.4", + "passport-totp": "^0.0.2", "pg-promise": "^12.1.3", "quill-delta": "^5.1.0", "sequelize": "^6.32.1", "sequelize-cli": "^6.4.1", "sequelize-mock": "^0.10.2", + "sequelize-simple-cache": "^1.3.5", "session-file-store": "^1.5.0", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", @@ -52,8 +55,7 @@ "uuid": "^8.3.2", "winston": "^3.8.1", "winston-transport": "^4.5.0", - "yauzl": "^3.2.0", - "sequelize-simple-cache": "^1.3.5" + "yauzl": "^3.2.0" }, "devDependencies": { "cross-env": "^7.0.3", diff --git a/backend/utils/auth.js b/backend/utils/auth.js index 72e3ddc7c..5c677a470 100644 --- a/backend/utils/auth.js +++ b/backend/utils/auth.js @@ -138,3 +138,17 @@ exports.generateOTP = function generateOTP() { return otp; } +/** +* Generate a Base32 secret for TOTP +* @returns {string} 32-digit secret +*/ +exports.generateBase32Secret = function generateBase32Secret(length = 32) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let secret = ''; + for (let i = 0; i < length; i++) { + const idx = Math.floor(Math.random() * alphabet.length); + secret += alphabet[idx]; + } + return secret; +} + diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 68fbf660b..f7c6cf7f3 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -12,7 +12,10 @@ const session = require('express-session'); const bodyParser = require('body-parser'); const Sequelize = require('sequelize'); const LocalStrategy = require("passport-local"); +const OrcidStrategy = require('passport-orcid').Strategy; const LdapStrategy = require('passport-ldapauth'); +const SamlStrategy = require('passport-saml').Strategy; +const TotpStrategy = require('passport-totp').Strategy; const {relevantFields} = require("../utils/auth"); const crypto = require("crypto"); const SequelizeStore = require('connect-session-sequelize')(session.Store); @@ -202,7 +205,7 @@ module.exports = class Server { #loginManagement() { this.logger.debug("Initialize Routes for auth..."); - passport.use(new LocalStrategy(async (username, password, cb) => { + passport.use('local', new LocalStrategy(async (username, password, cb) => { const user = await this.db.models['user'].find(username); if (!user) { @@ -223,44 +226,207 @@ module.exports = class Server { }); })); - // TODO: Temporary sever setting only for testing, Need to incorporate user specified domain into it. - passport.use('ldap-2fa', new LdapStrategy({ + passport.use("orcid-link", new OrcidStrategy( + { + clientID: process.env.ORCID_CLIENT_ID, + clientSecret: process.env.ORCID_CLIENT_SECRET, + callbackURL: process.env.ORCID_LINK_CALLBACK_URL, + passReqToCallback: true, + }, + async (req, accessToken, refreshToken, params, profile, done) => { + try { + const orcidId = params.orcid; + + if (!req.user) { + return done(null, false, { + message: "User must be logged in", + }); + } + + // Save ORCID iD to user's account + await this.db.models["user"].update( + { orcidId: orcidId }, + { where: { id: req.user.id } }, + ); + + // Return user data + return done(null, { + userId: req.user.id, + orcidId: orcidId, + action: "link", + }); + } catch (error) { + return done(error); + } + }, + ), + ); + + /** + * ORCID login method (first factor). + * Minimal approach: only allow login if the ORCID iD is already linked to an existing CARE user. + */ + passport.use("orcid-login", new OrcidStrategy( + { + clientID: process.env.ORCID_CLIENT_ID, + clientSecret: process.env.ORCID_CLIENT_SECRET, + callbackURL: process.env.ORCID_LOGIN_CALLBACK_URL, + }, + async (accessToken, refreshToken, params, profile, done) => { + try { + const orcidId = params.orcid; + if (!orcidId) { + return done(null, false, { message: "Missing ORCID iD." }); + } + + const user = await this.db.models['user'].findOne({ + where: { orcidId: orcidId }, + raw: true, + }); + + if (!user) { + return done(null, false, { message: "ORCID account not linked to a CARE user." }); + } + + return done(null, relevantFields(user)); + } catch (e) { + return done(e); + } + } + )); + + /** + * LDAP login method (first factor). + * Configuration is currently provided via environment variables. + * + * Required env vars: + * - LDAP_SERVER_URL + * - LDAP_BIND_DN + * - LDAP_BIND_CREDENTIALS + * - LDAP_SEARCH_BASE + * - LDAP_SEARCH_FILTER (optional; defaults to '(uid={{username}})') + */ + passport.use('ldap-login', new LdapStrategy({ server: { - url: 'ldap://localhost:389', - bindDN: 'cn=admin,dc=example,dc=org', - bindCredentials: 'admin', - searchBase: 'ou=users,dc=example,dc=org', - searchFilter: '(uid={{username}})' + url: process.env.LDAP_SERVER_URL, + bindDN: process.env.LDAP_BIND_DN, + bindCredentials: process.env.LDAP_BIND_CREDENTIALS, + searchBase: process.env.LDAP_SEARCH_BASE, + searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})', }, - passReqToCallback: true - }, - async (req, ldapUser, done) => { + passReqToCallback: true, + }, async (req, ldapUser, done) => { try { - // Check if there's a pending 2FA verification - if (!req.session || !req.session.twoFactorPending) { - return done(null, false, { - message: 'No pending 2FA verification' + const username = (req.body && (req.body.username || req.body.userName)) ? (req.body.username || req.body.userName) : null; + + // 1) Prefer explicit ldapUsername match + let user = null; + if (username) { + user = await this.db.models['user'].findOne({ + where: { ldapUsername: username }, + raw: true, }); } - - const {userId} = req.session.twoFactorPending - const user = await this.db.models['user'].findOne({ - where: { id: userId }, + + // 2) Fallback: try to match by email from LDAP profile, then bind ldapUsername + const ldapMail = ldapUser && (ldapUser.mail || ldapUser.email); + const ldapEmail = Array.isArray(ldapMail) ? ldapMail[0] : ldapMail; + if (!user && ldapEmail) { + const existing = await this.db.models['user'].findOne({ + where: { email: ldapEmail }, + raw: true, + }); + if (existing) { + user = existing; + if (username && !existing.ldapUsername) { + await this.db.models['user'].update( + { ldapUsername: username }, + { where: { id: existing.id } } + ); + } + } + } + + if (!user) { + return done(null, false, { message: "LDAP account not linked to a CARE user." }); + } + + return done(null, relevantFields(user)); + } catch (e) { + return done(e); + } + })); + + /** + * SAML login method (first factor). + * Configuration via environment variables for now. + * + * Required env vars: + * - SAML_ENTRY_POINT + * - SAML_ISSUER + * - SAML_CALLBACK_URL + * - SAML_CERT + */ + passport.use('saml-login', new SamlStrategy({ + entryPoint: process.env.SAML_ENTRY_POINT, + issuer: process.env.SAML_ISSUER, + callbackUrl: process.env.SAML_CALLBACK_URL, + cert: process.env.SAML_CERT, + }, async (profile, done) => { + try { + const nameId = profile && profile.nameID; + if (!nameId) { + return done(null, false, { message: "Missing SAML NameID." }); + } + + // 1) Prefer explicit samlNameId match + let user = await this.db.models['user'].findOne({ + where: { samlNameId: nameId }, raw: true, }); - + + // 2) Fallback: try to match by email and bind samlNameId + const email = profile && (profile.email || profile.mail || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']); + const samlEmail = Array.isArray(email) ? email[0] : email; + + if (!user && samlEmail) { + const existing = await this.db.models['user'].findOne({ + where: { email: samlEmail }, + raw: true, + }); + if (existing) { + user = existing; + if (!existing.samlNameId) { + await this.db.models['user'].update( + { samlNameId: nameId }, + { where: { id: existing.id } } + ); + } + } + } + if (!user) { - return done(null, false, { message: 'User not found' }); + return done(null, false, { message: "SAML account not linked to a CARE user." }); } - - // LDAP authentication successful - // Clear 2FA pending state - delete req.session.twoFactorPending; + return done(null, relevantFields(user)); - } catch (error) { - return done(error); + } catch (e) { + return done(e); + } + })); + + /** + * TOTP verification strategy for 2FA. + * Used in /auth/2fa/totp/verify; expects req.user.totpSecret to be set. + */ + passport.use('totp-verify', new TotpStrategy( + function(user, done) { + if (!user || !user.totpSecret) { + return done(null, null); + } + // 30 second time step + return done(null, user.totpSecret, 30); } - } )); // required to work -- defines strategy for storing user information diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index 455a950e5..4d10df354 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -7,9 +7,9 @@ * @author Nils Dycke, Dennis Zyska */ const passport = require('passport'); -const { generateToken, decodeToken, generateOTP, relevantFields } = require('../../utils/auth'); +const { generateToken, decodeToken, generateOTP, generateBase32Secret } = require('../../utils/auth'); -/** + /** * Route for user management * @param {Server} server main server instance */ @@ -93,21 +93,49 @@ module.exports = function (server) { return []; } + async function sendEmailOtp(userRecord) { + if (!userRecord || !userRecord.email) { + throw new Error("Email address missing for email 2FA."); + } + + const otp = generateOTP(); + const otpExpiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes expiry + + await server.db.models['user'].update( + { + twoFactorOtp: otp, + twoFactorOtpExpiresAt: otpExpiresAt + }, + { where: { id: userRecord.id } } + ); + + await server.sendMail( + userRecord.email, + "CARE - Two-Factor Authentication Code", + `Hello ${userRecord.userName}, +Your two-factor authentication code is: ${otp} + +This code will expire in 10 minutes. If you didn't request this code, please ignore this email. + +Thanks, +The CARE Team` + ); + } + /** - * Shared helper to decide whether 2FA is required for a login and, if so, - * to initiate the appropriate 2FA flow (email / TOTP). - * - * Returns true if a 2FA response has been sent and normal login should stop. + * Start 2FA if configured for the user. + * - If exactly 1 method is configured: start it immediately (send email OTP if needed). + * - If multiple methods are configured: require the client to select one via /auth/2fa/select. * - * @param {Object} req - * @param {Object} res - * @param {Object} user - plain user object returned by passport (relevantFields) - * @returns {Promise} + * Returns true if a response has been sent (2FA flow started / selection required). */ - async function initiateTwoFactorIfRequired(req, res, user) { - // Load full user record to get latest 2FA configuration + async function startTwoFactorIfConfigured(req, res, user, options = { mode: 'json' }) { + const mode = options.mode || 'json'; // 'json' | 'redirect' + const redirectedFrom = options.redirectedFrom || '/dashboard'; + const userRecord = await server.db.models['user'].findOne({ where: { id: user.id }, + raw: true, }); if (!userRecord) { @@ -115,101 +143,90 @@ module.exports = function (server) { } const methods = getTwoFactorMethods(userRecord); - - // No 2FA configured -> normal login if (!methods || methods.length === 0) { return false; } - // For now, pick the first configured method. - // Later, we can extend this to support user selection. - const primaryMethod = methods[0]; - - // Store 2FA pending state in session + // Store 2FA pending state; method may be selected later req.session.twoFactorPending = { userId: userRecord.id, - userData: user, - method: primaryMethod, - methods: methods + methods: methods, + method: null, + redirectedFrom: redirectedFrom, }; - // Email-based 2FA - if (primaryMethod === "email") { - const email = user.email || userRecord.email; - - if (!email) { - return res.status(400).json({ - message: "Email address is required for 2FA but not found for this user." - }); - } - - try { - // Generate and send OTP automatically - const otp = generateOTP(); - const otpExpiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes expiry - - // Store OTP in user record - await server.db.models['user'].update( - { - twoFactorOtp: otp, - twoFactorOtpExpiresAt: otpExpiresAt - }, - { where: { id: userRecord.id } } - ); - - // Send OTP via email - await server.sendMail( - email, - "CARE - Two-Factor Authentication Code", - `Hello ${user.userName}, -Your two-factor authentication code is: ${otp} - -This code will expire in 10 minutes. If you didn't request this code, please ignore this email. - -Thanks, -The CARE Team` - ); - - // Save session and return - session cookie will be sent automatically - req.session.save((err) => { - if (err) { - server.logger.error("Failed to save session: " + err); - return res.status(500).json({ message: "Failed to initiate 2FA verification." }); - } - - return res.status(200).json({ - requiresTwoFactor: true, - method: "email", - message: "Authentication code has been sent to your email. Please check." - }); - }); - return true; - } catch (error) { - server.logger.error("Failed to initiate 2FA: " + error); - res.status(500).json({ message: "Failed to initiate 2FA verification." }); - return true; - } - } - - // TOTP-based 2FA - if (primaryMethod === "totp") { + const respondJson = (payload) => { req.session.save((err) => { if (err) { server.logger.error("Failed to save session: " + err); return res.status(500).json({ message: "Failed to initiate 2FA verification." }); } + return res.status(200).json(payload); + }); + }; + + const redirectTo = (path) => { + req.session.save((err) => { + if (err) { + server.logger.error("Failed to save session: " + err); + return res.redirect('/login?error=twofactor-session-save-failed'); + } + return res.redirect(path); + }); + }; - return res.status(200).json({ - requiresTwoFactor: true, - method: "totp", - message: "Two-factor authentication is required." - }); + // Multiple methods -> require selection + if (methods.length > 1) { + if (mode === 'redirect') { + return redirectTo(`/2fa/select?redirectedFrom=${encodeURIComponent(redirectedFrom)}`), true; + } + respondJson({ + requiresTwoFactor: true, + selectionRequired: true, + methods: methods, }); return true; } - // Unsupported method – fall back to normal login - return false; + // Single method -> start immediately + const method = methods[0]; + req.session.twoFactorPending.method = method; + + try { + if (method === 'email') { + if (!userRecord.email) { + return res.status(400).json({ message: "Email address is required for email 2FA but not found for this user." }); + } + await sendEmailOtp(userRecord); + } else if (method === 'totp') { + if (!userRecord.totpSecret) { + return res.status(400).json({ message: "TOTP 2FA is enabled but not configured (missing secret)." }); + } + } else { + return res.status(400).json({ message: `Unsupported 2FA method: ${method}` }); + } + } catch (e) { + server.logger.error("Failed to initiate 2FA: " + e); + return res.status(500).json({ message: "Failed to initiate 2FA verification." }); + } + + if (mode === 'redirect') { + if (method === 'email') { + return redirectTo(`/2fa/verify/email?redirectedFrom=${encodeURIComponent(redirectedFrom)}`), true; + } + if (method === 'totp') { + return redirectTo(`/2fa/verify/totp?redirectedFrom=${encodeURIComponent(redirectedFrom)}`), true; + } + return redirectTo(`/login?error=unsupported-2fa-method`), true; + } + + respondJson({ + requiresTwoFactor: true, + selectionRequired: false, + method: method, + methods: methods, + }); + return true; } function ensureAuthenticated(req, res, next) { @@ -247,8 +264,8 @@ The CARE Team` } - // Check if 2FA is enabled for this user (multi-method aware) - const twoFactorHandled = await initiateTwoFactorIfRequired(req, res, user); + // Start 2FA if configured for this user + const twoFactorHandled = await startTwoFactorIfConfigured(req, res, user, { mode: 'json', redirectedFrom: req.query.redirectedFrom || '/dashboard' }); if (twoFactorHandled) { // 2FA response has been sent; stop normal login flow return; @@ -679,11 +696,16 @@ The CARE Team` * Called after password verification when user has 2FA enabled * Uses session to track 2FA state */ - server.app.post('/auth/2fa/resend-otp', async function (req, res) { + async function resendEmailOtpHandler(req, res) { // Check if session has 2FA pending state if (!req.session || !req.session.twoFactorPending) { return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); } + + // Must have selected email 2FA + if (req.session.twoFactorPending.method !== 'email') { + return res.status(400).json({ message: "Email 2FA is not the selected method. Please select email first." }); + } try { const { userId } = req.session.twoFactorPending; @@ -698,7 +720,7 @@ The CARE Team` if (!user || !methods.includes('email')) { // Clear invalid session state delete req.session.twoFactorPending; - return res.status(400).json({ message: "2FA is not enabled for this user." }); + return res.status(400).json({ message: "Email 2FA is not enabled for this user." }); } if (!user.email) { @@ -741,7 +763,9 @@ The CARE Team` server.logger.error("Failed to request OTP: " + error); return res.status(500).json({ message: "Internal server error" }); } - }); + } + + server.app.post('/auth/2fa/resend-otp', resendEmailOtpHandler); /** * Verify OTP and complete login @@ -758,9 +782,13 @@ The CARE Team` if (!req.session || !req.session.twoFactorPending) { return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); } + + if (req.session.twoFactorPending.method !== 'email') { + return res.status(400).json({ message: "Email 2FA is not the selected method." }); + } try { - const { userId, userData } = req.session.twoFactorPending; + const { userId } = req.session.twoFactorPending; // Get user details const user = await server.db.models['user'].findOne({ where: { id: userId } @@ -829,6 +857,135 @@ The CARE Team` } }); + /** + * Select a 2FA method when multiple are configured. + * If email is selected, the OTP will be sent after selection. + */ + server.app.post('/auth/2fa/select', async function (req, res) { + const { method } = req.body; + + if (!req.session || !req.session.twoFactorPending) { + return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); + } + + const pending = req.session.twoFactorPending; + if (!method || !pending.methods || !Array.isArray(pending.methods)) { + return res.status(400).json({ message: "Missing 2FA method selection." }); + } + + if (!pending.methods.includes(method)) { + return res.status(400).json({ message: "Selected 2FA method is not enabled for this user." }); + } + + // Load user for any required side effects (email OTP send / totp secret existence) + const userRecord = await server.db.models['user'].findOne({ + where: { id: pending.userId }, + raw: true, + }); + + if (!userRecord) { + delete req.session.twoFactorPending; + return res.status(400).json({ message: "User not found." }); + } + + pending.method = method; + req.session.twoFactorPending = pending; + + try { + if (method === 'email') { + if (!userRecord.email) { + return res.status(400).json({ message: "Email address not found. Cannot use email 2FA." }); + } + await sendEmailOtp(userRecord); + req.session.save(() => { + return res.status(200).json({ requiresTwoFactor: true, method: 'email' }); + }); + return; + } + + if (method === 'totp') { + if (!userRecord.totpSecret) { + return res.status(400).json({ message: "TOTP is enabled but not configured (missing secret)." }); + } + req.session.save(() => { + return res.status(200).json({ requiresTwoFactor: true, method: 'totp' }); + }); + return; + } + + return res.status(400).json({ message: `Unsupported 2FA method: ${method}` }); + } catch (e) { + server.logger.error("Failed to apply 2FA selection: " + e); + return res.status(500).json({ message: "Failed to start selected 2FA method." }); + } + }); + + /** + * Verify TOTP and complete login (uses passport-totp) + */ + server.app.post('/auth/2fa/totp/verify', async function (req, res, next) { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ message: "TOTP token is required." }); + } + + if (!req.session || !req.session.twoFactorPending) { + return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); + } + + if (req.session.twoFactorPending.method !== 'totp') { + return res.status(400).json({ message: "TOTP is not the selected 2FA method." }); + } + + try { + const { userId } = req.session.twoFactorPending; + const user = await server.db.models['user'].findOne({ where: { id: userId } }); + if (!user) { + delete req.session.twoFactorPending; + return res.status(400).json({ message: "User not found." }); + } + + const methods = getTwoFactorMethods(user); + if (!methods.includes('totp') || !user.totpSecret) { + delete req.session.twoFactorPending; + return res.status(400).json({ message: "TOTP 2FA is not enabled for this user." }); + } + + // Attach user and code for passport-totp verification + req.user = { id: user.id, totpSecret: user.totpSecret }; + req.body.code = String(token).replace(/\s/g, ''); + + passport.authenticate('totp-verify', async function (err, authedUser, info) { + if (err || !authedUser) { + return res.status(401).json({ message: "Invalid TOTP code." }); + } + + delete req.session.twoFactorPending; + + req.logIn(user, async function (err2) { + if (err2) { + return res.status(500).json({ message: "Failed to complete login." }); + } + + let transaction; + try { + transaction = await server.db.models['user'].sequelize.transaction(); + await server.db.models['user'].registerUserLogin(user.id, {transaction: transaction}); + await transaction.commit(); + } catch (e2) { + await transaction.rollback(); + } + + return res.status(200).json({ user: user }); + }); + })(req, res, next); + } catch (e) { + server.logger.error("Failed to verify TOTP: " + e); + return res.status(500).json({ message: "Internal server error" }); + } + }); + /** * Get 2FA status for current user */ @@ -881,7 +1038,7 @@ The CARE Team` const { method } = req.body; - // New 2FA methods: email and totp + // Supported 2FA methods: email and totp if (!method || !['email', 'totp'].includes(method)) { return res.status(400).json({ message: "Valid 2FA method is required (email or totp)." }); } @@ -899,6 +1056,10 @@ The CARE Team` return res.status(400).json({ message: "Email address is required to enable email 2FA." }); } + if (method === 'totp') { + return res.status(400).json({ message: "Use /auth/2fa/totp/setup/initiate and /auth/2fa/totp/setup/verify to enable TOTP (requires setup + verification)." }); + } + // Compute updated list of 2FA methods const currentMethods = Array.isArray(user.twoFactorMethods) ? user.twoFactorMethods.slice() : []; if (!currentMethods.includes(method)) { @@ -965,6 +1126,153 @@ The CARE Team` } }); + + + /** + * Initiate TOTP setup (authenticated user). + * Returns an otpauth URL that the frontend can convert to a QR code. + */ + server.app.post('/auth/2fa/totp/setup/initiate', ensureAuthenticated, async function (req, res) { + const user = await server.db.models['user'].findOne({ where: { id: req.user.id }, raw: true }); + if (!user) { + return res.status(404).json({ message: "User not found." }); + } + + const secretBase32 = generateBase32Secret(32); + const label = encodeURIComponent(`CARE (${user.userName})`); + const issuer = encodeURIComponent('CARE'); + const otpauthUrl = `otpauth://totp/${label}?secret=${secretBase32}&issuer=${issuer}`; + + req.session.totpSetupPending = { + secretBase32: secretBase32, + }; + + req.session.save((err) => { + if (err) { + server.logger.error("Failed to save session for TOTP setup: " + err); + return res.status(500).json({ message: "Failed to initiate TOTP setup." }); + } + return res.status(200).json({ + otpauthUrl: otpauthUrl, + secretBase32: secretBase32, + }); + }); + }); + + /** + * Verify TOTP setup (authenticated user) and persist secret. + * Adds "totp" to twoFactorMethods on success. + */ + server.app.post('/auth/2fa/totp/setup/verify', ensureAuthenticated, async function (req, res, next) { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ message: "TOTP token is required." }); + } + + if (!req.session || !req.session.totpSetupPending || !req.session.totpSetupPending.secretBase32) { + return res.status(400).json({ message: "No pending TOTP setup found. Please initiate setup again." }); + } + + const secretBase32 = req.session.totpSetupPending.secretBase32; + + // Use passport-totp strategy for verification by attaching a temporary user + req.user = { id: req.user.id, totpSecret: secretBase32 }; + req.body.code = String(token).replace(/\s/g, ''); + + passport.authenticate('totp-verify', async function (err, authedUser, info) { + if (err || !authedUser) { + return res.status(401).json({ message: "Invalid TOTP code." }); + } + + const user = await server.db.models['user'].findOne({ where: { id: req.user.id }, raw: true }); + if (!user) { + return res.status(404).json({ message: "User not found." }); + } + + const currentMethods = Array.isArray(user.twoFactorMethods) ? user.twoFactorMethods.slice() : []; + if (!currentMethods.includes('totp')) { + currentMethods.push('totp'); + } + + await server.db.models['user'].update( + { totpSecret: secretBase32, twoFactorMethods: currentMethods }, + { where: { id: user.id } } + ); + + delete req.session.totpSetupPending; + + return res.status(200).json({ + message: "TOTP has been successfully configured.", + twoFactorMethods: currentMethods, + }); + })(req, res, next); + }); + + /** + * ORCID login method + */ + server.app.get('/auth/login/orcid', passport.authenticate('orcid-login')); + + server.app.get('/auth/login/orcid/callback', + passport.authenticate('orcid-login', { failureRedirect: '/login?error=orcid-login-failed' }), + async function (req, res, next) { + const user = req.user; + const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect', redirectedFrom: '/dashboard' }); + if (handled) return; + + req.logIn(user, async function (err) { + if (err) return next(err); + await server.db.models['user'].registerUserLogin(user.id); + return res.redirect('/dashboard'); + }); + } + ); + + /** + * LDAP login method (JSON-based, similar to /auth/login) + */ + server.app.post('/auth/login/ldap', function (req, res, next) { + passport.authenticate('ldap-login', async function (err, user, info) { + if (err) { + server.logger.error("LDAP login failed: " + err); + return res.status(500).send("Failed to login"); + } + if (!user) { + return res.status(401).send(info || { message: "LDAP login failed." }); + } + + const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'json', redirectedFrom: req.query.redirectedFrom || '/dashboard' }); + if (handled) return; + + req.logIn(user, async function (err2) { + if (err2) return next(err2); + await server.db.models['user'].registerUserLogin(user.id); + return res.status(200).send({ user: user }); + }); + })(req, res, next); + }); + + /** + * SAML login method + */ + server.app.get('/auth/login/saml', passport.authenticate('saml-login')); + + server.app.post('/auth/login/saml/callback', + passport.authenticate('saml-login', { failureRedirect: '/login?error=saml-login-failed' }), + async function (req, res, next) { + const user = req.user; + const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect', redirectedFrom: '/dashboard' }); + if (handled) return; + + req.logIn(user, async function (err) { + if (err) return next(err); + await server.db.models['user'].registerUserLogin(user.id); + return res.redirect('/dashboard'); + }); + } + ); + /** * Initiate ORCID linking * Passport handles: redirect to ORCID, state generation, etc. @@ -979,6 +1287,7 @@ The CARE Team` * Passport handles: code exchange, token verification, etc. */ server.app.get('/auth/orcid/link/callback', + ensureAuthenticated, passport.authenticate('orcid-link', { session: false, failureRedirect: '/dashboard?error=orcid-link-failed' diff --git a/frontend/src/auth/TwoFactorVerifyEmail.vue b/frontend/src/auth/TwoFactorVerifyEmail.vue index 314777016..725df5b3b 100644 --- a/frontend/src/auth/TwoFactorVerifyEmail.vue +++ b/frontend/src/auth/TwoFactorVerifyEmail.vue @@ -222,7 +222,7 @@ export default { try { const response = await axios.post( - getServerURL() + "/auth/2fa/resend", + getServerURL() + "/auth/2fa/resend-otp", {}, { validateStatus: function (status) { From 78af46addb100f223da4f9ac16adbd70377880e5 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Wed, 11 Feb 2026 21:17:08 +0100 Subject: [PATCH 10/58] feat: implement 2fa workflow --- frontend/src/auth/Login.vue | 33 ++- frontend/src/auth/TwoFactorSelect.vue | 249 ++++++++++++++++++++++ frontend/src/auth/TwoFactorVerifyTotp.vue | 224 +++++++++++++++++++ frontend/src/router.js | 12 ++ 4 files changed, 509 insertions(+), 9 deletions(-) create mode 100644 frontend/src/auth/TwoFactorSelect.vue create mode 100644 frontend/src/auth/TwoFactorVerifyTotp.vue diff --git a/frontend/src/auth/Login.vue b/frontend/src/auth/Login.vue index 5dd18ea97..181715550 100644 --- a/frontend/src/auth/Login.vue +++ b/frontend/src/auth/Login.vue @@ -289,43 +289,58 @@ export default { credentials, { validateStatus: function (status) { - return status === 200 || status === 401; + return status === 200 || status === 400 || status === 401; }, withCredentials: true }); - if (response.status === 401) { + if (response.status === 400 || response.status === 401) { // Check if the error is due to unverified email if (response.data.emailNotVerified) { this.showEmailVerificationModal(response.data.email); } throw response.data.message; } - + // Check if 2FA is required if (response.status === 200) { if (response.data.requiresTwoFactor) { - const { method } = response.data.requiresTwoFactor; + const { method, methods, selectionRequired } = response.data; + // If multiple methods are enabled, let the user choose + if (selectionRequired) { + await this.$router.push({ + name: "2fa-select", + query: { + methods: Array.isArray(methods) ? methods.join(",") : "", + redirectedFrom: this.$route.query.redirectedFrom + } + }); + return; + } + + // Single method, go directly to corresponding verification if (method === "email") { await this.$router.push({ name: "2fa-verify-email", query: { - method: response.data.method, redirectedFrom: this.$route.query.redirectedFrom } }); return; - } - if (method === "ldapauth") { + + if (method === "totp") { await this.$router.push({ - name: "2fa-verify-ldap", + name: "2fa-verify-totp", query: { - method: response.data.method, redirectedFrom: this.$route.query.redirectedFrom } }); return; } + // Unknown method: surface a clear error + this.showError = true; + this.errorMessage = "Unsupported 2FA method returned from server."; + return; } // Normal login flow (no 2FA) await this.$router.push(this.$route.query.redirectedFrom || '/dashboard') diff --git a/frontend/src/auth/TwoFactorSelect.vue b/frontend/src/auth/TwoFactorSelect.vue new file mode 100644 index 000000000..2986ba39f --- /dev/null +++ b/frontend/src/auth/TwoFactorSelect.vue @@ -0,0 +1,249 @@ + + + + + + diff --git a/frontend/src/auth/TwoFactorVerifyTotp.vue b/frontend/src/auth/TwoFactorVerifyTotp.vue new file mode 100644 index 000000000..acd306d39 --- /dev/null +++ b/frontend/src/auth/TwoFactorVerifyTotp.vue @@ -0,0 +1,224 @@ + + + + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 255789872..794265fb6 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -39,12 +39,24 @@ const routes = [ component: () => import("@/auth/TwoFactorVerifyEmail.vue"), meta: { requiresAuth: false } }, + { + path: "/2fa/verify/totp", + name: "2fa-verify-totp", + component: () => import("@/auth/TwoFactorVerifyTotp.vue"), + meta: { requiresAuth: false } + }, { path: '/2fa/ldap/verify', name: '2fa-verify-ldap', component: () => import("@/auth/TwoFactorVerifyLDAP.vue"), meta: { requiresAuth: false } }, + { + path: "/2fa/select", + name: "2fa-select", + component: () => import("@/auth/TwoFactorSelect.vue"), + meta: { requiresAuth: false } + }, { path: "/register", name: "register", From b40121cd45321793a7932dd3bfad8979029e7918 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Fri, 13 Feb 2026 14:53:09 +0100 Subject: [PATCH 11/58] feat: update orcid and LDAP login behaviour --- backend/webserver/Server.js | 65 ++++++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index f7c6cf7f3..c2d4e8ed5 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -264,13 +264,18 @@ module.exports = class Server { /** * ORCID login method (first factor). - * Minimal approach: only allow login if the ORCID iD is already linked to an existing CARE user. + * + * Behaviour: + * - If an existing user with this ORCID iD exists, log that user in. + * - Otherwise, automatically create a new local user account linked to this ORCID iD. */ passport.use("orcid-login", new OrcidStrategy( { + sandbox: process.env.NODE_ENV !== 'production', clientID: process.env.ORCID_CLIENT_ID, clientSecret: process.env.ORCID_CLIENT_SECRET, callbackURL: process.env.ORCID_LOGIN_CALLBACK_URL, + passReqToCallback : true }, async (accessToken, refreshToken, params, profile, done) => { try { @@ -279,13 +284,36 @@ module.exports = class Server { return done(null, false, { message: "Missing ORCID iD." }); } - const user = await this.db.models['user'].findOne({ + // 1) Try to find existing user by ORCID iD + let user = await this.db.models['user'].findOne({ where: { orcidId: orcidId }, raw: true, }); if (!user) { - return done(null, false, { message: "ORCID account not linked to a CARE user." }); + // 2) No linked user yet -> auto-create a new user + const email = + (Array.isArray(profile?.emails) && profile.emails[0]?.value) || + profile?.email || + null; + const firstName = profile?.name?.givenName || null; + const lastName = profile?.name?.familyName || null; + + const userData = { + orcidId: orcidId, + email: email, + firstName: firstName, + lastName: lastName, + // External accounts are created with a random password by add() + // and get the default "user" role via hooks. + acceptTerms: true, + acceptStats: false, + emailVerified: !!email, // treat ORCID email as verified if present + }; + + const created = await this.db.models['user'].add(userData, {}); + // Ensure we pass a plain object into relevantFields + user = created && created.get ? created.get({ plain: true }) : created; } return done(null, relevantFields(user)); @@ -297,6 +325,11 @@ module.exports = class Server { /** * LDAP login method (first factor). + * + * Behaviour: + * - If an existing CARE user is linked via ldapUsername or email, log that user in. + * - Otherwise, automatically create a new CARE user account linked to this LDAP identity. + * * Configuration is currently provided via environment variables. * * Required env vars: @@ -348,7 +381,31 @@ module.exports = class Server { } if (!user) { - return done(null, false, { message: "LDAP account not linked to a CARE user." }); + // 3) No linked user yet -> auto-create a new user + if (!ldapEmail) { + return done(null, false, { message: "LDAP account has no email; cannot create local user." }); + } + + const firstName = + (Array.isArray(ldapUser?.givenName) ? ldapUser.givenName[0] : ldapUser?.givenName) || + ldapUser?.cn || + null; + const lastName = + (Array.isArray(ldapUser?.sn) ? ldapUser.sn[0] : ldapUser?.sn) || + null; + + const userData = { + email: ldapEmail, + ldapUsername: username || (Array.isArray(ldapUser?.uid) ? ldapUser.uid[0] : ldapUser?.uid) || null, + firstName: firstName, + lastName: lastName, + acceptTerms: true, + acceptStats: false, + emailVerified: true, + }; + + const created = await this.db.models['user'].add(userData, {}); + user = created && created.get ? created.get({ plain: true }) : created; } return done(null, relevantFields(user)); From b97ea773aeb744ccfbed5917af8827dc21ad5189 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Fri, 13 Feb 2026 14:57:35 +0100 Subject: [PATCH 12/58] feat: remove first factor setting, add 2fa setting, refactor backend disable route --- backend/webserver/routes/auth.js | 68 +- frontend/src/auth/TwoFactorSettingsModal.vue | 705 +++++++++---------- 2 files changed, 389 insertions(+), 384 deletions(-) diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index 4d10df354..630153e08 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -1088,40 +1088,68 @@ The CARE Team` }); /** - * Disable 2FA for a user + * Disable a specific 2FA method for the current user. + * If this was the last enabled method, 2FA is fully disabled and related fields are cleared. */ - server.app.post('/auth/2fa/disable', async function (req, res) { - if (!req.user) { - return res.status(401).json({ message: "You must be logged in to disable 2FA." }); + server.app.post('/auth/2fa/disable/:method', ensureAuthenticated, async function (req, res) { + const method = req.params.method; + + // TODO: Methods are hard coded now. Could they be dynamic? + if (!['email', 'totp'].includes(method)) { + return res.status(400).json({ message: "Valid 2FA method is required (email or totp)." }); } - + try { const user = await server.db.models['user'].findOne({ where: { id: req.user.id } }); - + if (!user) { return res.status(404).json({ message: "User not found." }); } - - // Disable 2FA and clear related fields + + const currentMethods = getTwoFactorMethods(user); + + if (!currentMethods.includes(method)) { + return res.status(400).json({ message: `2FA method '${method}' is not enabled for this user.` }); + } + + const updatedMethods = currentMethods.filter((m) => m !== method); + + const updateData = { + twoFactorMethods: updatedMethods, + }; + + // If we remove email, clear any pending email OTP state + if (method === 'email') { + updateData.twoFactorOtp = null; + updateData.twoFactorOtpExpiresAt = null; + } + + // If we remove TOTP, clear the TOTP secret + if (method === 'totp') { + updateData.totpSecret = null; + } + + // If no methods remain, fully disable 2FA state + if (updatedMethods.length === 0) { + updateData.twoFactorOtp = null; + updateData.twoFactorOtpExpiresAt = null; + updateData.totpSecret = null; + } + await server.db.models['user'].update( - { - twoFactorOtp: null, - twoFactorOtpExpiresAt: null, - twoFactorMethods: [], - totpSecret: null, - }, + updateData, { where: { id: user.id } } ); - - return res.status(200).json({ - message: "2FA has been disabled.", - twoFactorMethods: [] + + return res.status(200).json({ + message: `2FA method '${method}' has been disabled.`, + twoFactorMethods: updatedMethods, }); - + } catch (error) { - server.logger.error("Failed to disable 2FA: " + error); + server.logger.error("Failed to disable 2FA method: " + error); return res.status(500).json({ message: "Internal server error" }); } }); diff --git a/frontend/src/auth/TwoFactorSettingsModal.vue b/frontend/src/auth/TwoFactorSettingsModal.vue index 7f991efc8..9efd60105 100644 --- a/frontend/src/auth/TwoFactorSettingsModal.vue +++ b/frontend/src/auth/TwoFactorSettingsModal.vue @@ -16,271 +16,161 @@

Loading...

-
- -
+ +
- Current Status: 2FA is disabled + Status: No 2FA methods enabled
- Current Status: 2FA is enabled + Status: {{ enabledMethods.length }} method(s) enabled
- - -
-

- Choose a verification method to secure your account -

- - -
- -
-
- -
- -

- Receive a 6-digit code via email when logging in -

- Recommended -
-
-
- - -
-
- -
- -

- Verify your identity using your ORCID id -

- Requires ORCID account -
-
+ +
+
+
+
Email Verification
+

+ Receive a 6-digit code via email when logging in +

- - -
-
- -
- -

- Verify using your institutional credentials -

- For institutional users -
-
+
+ +
- - -
- -
-
- - No additional setup required for email verification -
-

- Codes will be sent to: - {{ userEmail || "Not set" }} +

+ + Codes will be sent to: {{ userEmail }} +
+
+ + You need to add and verify an email address in your profile first +
+
+ +
+
+
+
Authenticator App (TOTP)
+

+ Use an authenticator app like Google Authenticator

-
- - You need to add and verify an email address in your profile - first -
- - -
-
-

- You need to link your ORCID account first -

- -
- You will be redirected to ORCID.org to authorize +
+ + +
+
+ +
+
+
+ + Scan the QR code with your authenticator app, then enter the + code to verify setup. +
+
+
+ TOTP QR Code +
+
+ Generating QR code...
-
- - ORCID linked: {{ orcidId }} +
+ Manual entry: {{ totpSecret }}
-
- - -
-
- +
+
- Enter your institution's domain to simplify login -
-
-
-
-
- - -
-
-
Currently Active Method
- - -
-
-
-
-
Email Verification
-
- Codes sent to: {{ userEmail }} -
-
- Active + Enter the 6-digit code from your authenticator app
-
- - -
-
-
-
-
ORCID Authentication
-
- - Linked ORCID: {{ orcidId }} -
-
- Active -
+
+
+
- -
-
-
-
-
- Institutional Authentication (LDAP) -
-
- Domain: {{ ldapDomain }} -
-
- Active -
-
-
- - -
-
- - How it works -
-

- {{ getMethodDescription(currentMethod) }} -

-
+
+ + TOTP is configured and active
+ + +
+
+ + How it works +
+

+ When logging in, you'll be asked to verify using one of your enabled + methods. If multiple methods are enabled, you can choose which one + to use. +

+
@@ -294,6 +184,7 @@ import getServerURL from "@/assets/serverUrl"; /** * Modal for managing two-factor authentication settings + * Supports email and TOTP (authenticator app) methods independently. * * @author: Linyin Huang */ @@ -305,46 +196,34 @@ export default { // Loading states isLoading: false, isSubmitting: false, - isLinking: false, - - // 2FA state - twoFactorEnabled: false, - currentMethod: "", - orcidId: "", - ldapDomain: "", - // Setup - selectedMethod: "email", // default - orcidLinked: false, + // 2FA state (from backend) + twoFactorMethods: [], + hasEmail: false, + hasTotp: false, + userEmail: null, + + // UI state + emailEnabled: false, + totpEnabled: false, + + // TOTP setup state + isSettingUpTotp: false, + totpSetupStep: null, // 'initiate' | null + totpQrCode: null, + totpSecret: null, + totpVerificationCode: "", }; }, computed: { user() { return this.$store.getters["auth/getUser"]; }, - userEmail() { - return this.user.email; + enabledMethods() { + return this.twoFactorMethods || []; }, - hasEmail() { - return !!this.userEmail; - }, - canEnable() { - if (!this.selectedMethod) return false; - - if (this.selectedMethod === "email") { - return this.hasEmail; - } - - if (this.selectedMethod === "orcId") { - return this.orcidLinked; - } - - // LDAP can be enabled without domain - if (this.selectedMethod === "ldapauth") { - return true; - } - - return false; + canVerifyTotpSetup() { + return /^[0-9]{6}$/.test(this.totpVerificationCode); }, }, methods: { @@ -361,13 +240,14 @@ export default { }); if (response.status === 200) { - this.twoFactorEnabled = response.data.twoFactorEnabled || false; - this.currentMethod = response.data.twoFactorMethod; - this.orcidId = response.data.orcidId || ""; - this.ldapDomain = response.data.ldapDomain || ""; - - // Check if ORCID is linked - this.orcidLinked = !!this.orcidId; + this.twoFactorMethods = response.data.twoFactorMethods || []; + this.hasEmail = response.data.email || false; + this.hasTotp = response.data.hasTotp || false; + this.userEmail = response.data.email || null; + + // Update UI toggles + this.emailEnabled = this.twoFactorMethods.includes("email"); + this.totpEnabled = this.twoFactorMethods.includes("totp"); } } catch (error) { this.eventBus.emit("toast", { @@ -381,28 +261,72 @@ export default { this.isLoading = false; } }, - selectMethod(method) { - this.selectedMethod = method; + async toggleEmail2FA() { + if (this.emailEnabled) { + await this.enableEmail2FA(); + } else { + await this.disableEmail2FA(); + } }, - async linkOrcid() { - this.isLinking = true; + async enableEmail2FA() { + if (!this.hasEmail) { + this.eventBus.emit("toast", { + title: "Email required", + message: + "Please add and verify an email address in your profile first", + variant: "warning", + }); + this.emailEnabled = false; + return; + } + + this.isSubmitting = true; try { - // Redirect to ORCID linking endpoint - window.location.href = getServerURL() + "/auth/orcid/link"; + const response = await axios.post( + getServerURL() + "/auth/2fa/enable", + { method: "email" }, + { + validateStatus: (status) => status === 200 || status === 400, + withCredentials: true, + }, + ); + + if (response.status === 200) { + this.eventBus.emit("toast", { + title: "Email 2FA enabled", + message: "Email verification has been enabled", + variant: "success", + }); + await this.load2FAStatus(); + } else { + this.emailEnabled = false; + this.eventBus.emit("toast", { + title: "Failed to enable email 2FA", + message: response.data.message || "Failed to enable email 2FA", + variant: "danger", + }); + } } catch (error) { + this.emailEnabled = false; this.eventBus.emit("toast", { - title: "Failed to link ORCID", + title: "Failed to enable email 2FA", message: error.response?.data?.message || - "An error occurred while linking ORCID", + "An error occurred while enabling email 2FA", variant: "danger", }); - this.isLinking = false; + } finally { + this.isSubmitting = false; } }, - async unlinkOrcid() { - if (!confirm("Are you sure you want to unlink your ORCID account?")) { + async disableEmail2FA() { + if ( + !confirm( + "Are you sure you want to disable email 2FA? This will reduce your account security.", + ) + ) { + this.emailEnabled = true; return; } @@ -410,7 +334,7 @@ export default { try { const response = await axios.post( - getServerURL() + "/auth/orcid/unlink", + getServerURL() + "/auth/2fa/disable/email", {}, { validateStatus: (status) => status === 200 || status === 400, @@ -420,96 +344,150 @@ export default { if (response.status === 200) { this.eventBus.emit("toast", { - title: "ORCID unlinked", - message: "Your ORCID account has been successfully unlinked", + title: "Email 2FA disabled", + message: "Email verification has been disabled", variant: "success", }); - - // Reload status - setTimeout(async () => { - await this.load2FAStatus(); - }, 1500); + await this.load2FAStatus(); } else { + this.emailEnabled = true; this.eventBus.emit("toast", { - title: "Failed to unlink ORCID", - message: response.data.message || "Failed to unlink ORCID account", + title: "Failed to disable email 2FA", + message: response.data.message || "Failed to disable email 2FA", variant: "danger", }); } } catch (error) { + this.emailEnabled = true; this.eventBus.emit("toast", { - title: "Failed to unlink ORCID", + title: "Failed to disable email 2FA", message: error.response?.data?.message || - "An error occurred while unlinking ORCID", + "An error occurred while disabling email 2FA", variant: "danger", }); } finally { this.isSubmitting = false; } }, - async enable2FA() { - if (!this.canEnable) return; - - this.isSubmitting = true; + async toggleTotp2FA() { + if (this.totpEnabled) { + await this.disableTotp2FA(); + } else { + await this.initiateTotpSetup(); + } + }, + async initiateTotpSetup() { + this.isSettingUpTotp = true; + this.totpSetupStep = "initiate"; + this.totpQrCode = null; + this.totpSecret = null; + this.totpVerificationCode = ""; try { - const payload = { - method: this.selectedMethod, - }; + const response = await axios.post( + getServerURL() + "/auth/2fa/totp/setup/initiate", + {}, + { + validateStatus: (status) => status === 200 || status === 400, + withCredentials: true, + }, + ); - // Add method-specific data - if (this.selectedMethod === "ldapauth" && this.ldapDomain) { - payload.ldapDomain = this.ldapDomain; + if (response.status === 200) { + this.totpSecret = response.data.secretBase32; + + // Generate QR code from otpauth URL + // Using a QR code library would be better, but for now we can use an online service + // or require a library like qrcode.js + const otpauthUrl = response.data.otpauthUrl; + // TODO: Change it to a npm package + this.totpQrCode = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent( + otpauthUrl, + )}`; + } else { + this.isSettingUpTotp = false; + this.totpEnabled = false; + this.eventBus.emit("toast", { + title: "Failed to start TOTP setup", + message: response.data.message || "Failed to initiate TOTP setup", + variant: "danger", + }); } + } catch (error) { + this.isSettingUpTotp = false; + this.totpEnabled = false; + this.eventBus.emit("toast", { + title: "Failed to start TOTP setup", + message: + error.response?.data?.message || + "An error occurred while starting TOTP setup", + variant: "danger", + }); + } + }, + async verifyTotpSetup() { + if (!this.canVerifyTotpSetup) { + return; + } + this.isSubmitting = true; + + try { const response = await axios.post( - getServerURL() + "/auth/2fa/enable", - payload, + getServerURL() + "/auth/2fa/totp/setup/verify", + { token: this.totpVerificationCode }, { - validateStatus: (status) => status === 200 || status === 400, + validateStatus: (status) => + status === 200 || status === 400 || status === 401, withCredentials: true, }, ); if (response.status === 200) { this.eventBus.emit("toast", { - title: "2FA enabled", - message: "Two-factor authentication has been successfully enabled!", + title: "TOTP enabled", + message: "Authenticator app has been successfully configured", variant: "success", }); - // Reload status - setTimeout(async () => { - await this.load2FAStatus(); - }, 1500); + this.isSettingUpTotp = false; + this.totpSetupStep = null; + await this.load2FAStatus(); } else { this.eventBus.emit("toast", { - title: "Failed to enable 2FA", - message: - response.data.message || - "Failed to enable two-factor authentication", + title: "Verification failed", + message: response.data.message || "Invalid code. Please try again.", variant: "danger", }); } } catch (error) { this.eventBus.emit("toast", { - title: "Failed to enable 2FA", + title: "Verification failed", message: error.response?.data?.message || - "An error occurred while enabling 2FA", + "Failed to verify TOTP code. Please try again.", variant: "danger", }); } finally { this.isSubmitting = false; } }, - async disable2FA() { + cancelTotpSetup() { + this.isSettingUpTotp = false; + this.totpSetupStep = null; + this.totpQrCode = null; + this.totpSecret = null; + this.totpVerificationCode = ""; + this.totpEnabled = false; + }, + async disableTotp2FA() { if ( !confirm( - "Are you sure you want to disable Two-Factor Authentication? This will reduce your account security.", + "Are you sure you want to disable TOTP 2FA? This will reduce your account security.", ) ) { + this.totpEnabled = true; return; } @@ -517,7 +495,7 @@ export default { try { const response = await axios.post( - getServerURL() + "/auth/2fa/disable", + getServerURL() + "/auth/2fa/disable/totp", {}, { validateStatus: (status) => status === 200 || status === 400, @@ -527,89 +505,88 @@ export default { if (response.status === 200) { this.eventBus.emit("toast", { - title: "2FA disabled", - message: "Two-factor authentication has been disabled", + title: "TOTP disabled", + message: "TOTP has been disabled", variant: "success", }); - - // Reload status and close modal - setTimeout(async () => { - await this.load2FAStatus(); - this.$refs.modal.close(); - }, 1500); + await this.load2FAStatus(); } else { + this.totpEnabled = true; this.eventBus.emit("toast", { - title: "Failed to disable 2FA", - message: - response.data.message || - "Failed to disable two-factor authentication", + title: "Failed to disable TOTP", + message: response.data.message || "Failed to disable TOTP", variant: "danger", }); } } catch (error) { + this.totpEnabled = true; this.eventBus.emit("toast", { - title: "Failed to disable 2FA", + title: "Failed to disable TOTP", message: error.response?.data?.message || - "An error occurred while disabling 2FA", + "An error occurred while disabling TOTP", variant: "danger", }); } finally { this.isSubmitting = false; } }, - resetForm() { - this.selectedMethod = "email"; - this.ldapDomain = ""; - this.orcidLinked = false; + formatTotpCode() { + this.totpVerificationCode = this.totpVerificationCode.replace( + /[^0-9]/g, + "", + ); }, - getMethodDescription(method) { - const descriptions = { - email: - "When logging in, a 6-digit verification code will be sent to your email", - orcId: - "When logging in, you will be redirected to ORCID.org for authentication", - ldapauth: - "When logging in, you will need to enter your institutional credentials", - }; - return descriptions[method] || ""; + resetForm() { + this.emailEnabled = false; + this.totpEnabled = false; + this.isSettingUpTotp = false; + this.totpSetupStep = null; + this.totpQrCode = null; + this.totpSecret = null; + this.totpVerificationCode = ""; }, }, }; From 044f1555b8664b99c3f195272dad4d56280f540f Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 15 Feb 2026 15:42:05 +0100 Subject: [PATCH 13/58] fix: fix totp activating flow --- frontend/src/auth/TwoFactorSettingsModal.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/auth/TwoFactorSettingsModal.vue b/frontend/src/auth/TwoFactorSettingsModal.vue index 9efd60105..8d78d7fcd 100644 --- a/frontend/src/auth/TwoFactorSettingsModal.vue +++ b/frontend/src/auth/TwoFactorSettingsModal.vue @@ -372,9 +372,9 @@ export default { }, async toggleTotp2FA() { if (this.totpEnabled) { - await this.disableTotp2FA(); - } else { await this.initiateTotpSetup(); + } else { + await this.disableTotp2FA(); } }, async initiateTotpSetup() { From c392173dd2514955d1c20741f4598279c9ca169f Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 15 Feb 2026 15:47:48 +0100 Subject: [PATCH 14/58] fix: remove passport-totp and adopt otpauth --- backend/package-lock.json | 416 ++++++++++++++++++++++++++++--- backend/package.json | 6 +- backend/utils/auth.js | 15 -- backend/webserver/Server.js | 15 -- backend/webserver/routes/auth.js | 177 +++++++------ 5 files changed, 468 insertions(+), 161 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index e10f19b9d..478f27f97 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -27,8 +27,12 @@ "mustache-express": "^1.3.2", "nodemailer": "^6.9.16", "objects-to-csv": "^1.3.6", + "otpauth": "^9.2.2", "passport": "^0.6.0", + "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", + "passport-orcid": "^0.0.4", + "passport-saml": "^3.2.4", "pg-promise": "^12.1.3", "quill-delta": "^5.1.0", "sequelize": "^6.32.1", @@ -53,6 +57,18 @@ "node": ">=18.0.0" } }, + "../utils/modules/editor-delta-conversion": { + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "quill": "^2.0.2", + "quill-delta": "^5.1.0" + }, + "devDependencies": { + "cross-env": "^7.0.3", + "jest": "^26.6.3" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -1152,6 +1168,18 @@ "semver": "bin/semver.js" } }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@one-ini/wasm": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", @@ -1285,6 +1313,15 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/ldapjs": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-2.2.5.tgz", + "integrity": "sha512-Lv/nD6QDCmcT+V1vaTRnEKE8UgOilVv5pHcQuzkU1LcRe4mbHHuUo/KHi0LKrpdHhQY8FJzryF38fcVdeUIrzg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", @@ -1329,11 +1366,27 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true }, + "node_modules/@xmldom/xmldom": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", + "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", + "deprecated": "this version is no longer supported, please update to at least 0.8.*", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1437,6 +1490,15 @@ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asn1.js": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", @@ -1456,6 +1518,15 @@ "node": ">=14.0.0" } }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -1622,6 +1693,18 @@ "@babel/core": "^7.0.0" } }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "license": "MIT", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/bagpipe": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/bagpipe/-/bagpipe-0.3.5.tgz", @@ -1640,6 +1723,15 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -1653,6 +1745,12 @@ "node": ">= 10.0.0" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -2204,6 +2302,12 @@ "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==" }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2477,13 +2581,8 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/editor-delta-conversion": { - "version": "1.0.0", - "resolved": "file:../utils/modules/editor-delta-conversion", - "license": "Apache-2.0", - "dependencies": { - "quill": "^2.0.2", - "quill-delta": "^5.1.0" - } + "resolved": "../utils/modules/editor-delta-conversion", + "link": true }, "node_modules/editorconfig": { "version": "1.0.4", @@ -2816,11 +2915,6 @@ "es5-ext": "~0.10.14" } }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3035,6 +3129,15 @@ "type": "^2.7.2" } }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, "node_modules/fast-diff": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", @@ -4634,6 +4737,62 @@ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, + "node_modules/ldap-filter": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.3.3.tgz", + "integrity": "sha512-/tFkx5WIn4HuO+6w9lsfxq4FN3O+fDZeO9Mek8dCD8rTUpqzRa766BOBO7BcGkn3X86m5+cBm1/2S/Shzz7gMg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ldapauth-fork": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/ldapauth-fork/-/ldapauth-fork-5.0.5.tgz", + "integrity": "sha512-LWUk76+V4AOZbny/3HIPQtGPWZyA3SW2tRhsWIBi9imP22WJktKLHV1ofd8Jo/wY7Ve6vAT7FCI5mEn3blZTjw==", + "license": "MIT", + "dependencies": { + "@types/ldapjs": "^2.2.2", + "bcryptjs": "^2.4.0", + "ldapjs": "^2.2.1", + "lru-cache": "^7.10.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ldapauth-fork/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ldapjs": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-2.3.3.tgz", + "integrity": "sha512-75QiiLJV/PQqtpH+HGls44dXweviFwQ6SiIK27EqzKQ5jU/7UFrl2E5nLdQ3IYRBzJ/AVFJI66u0MZ0uofKYwg==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "abstract-logging": "^2.0.0", + "asn1": "^0.2.4", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "ldap-filter": "^0.3.3", + "once": "^1.4.0", + "vasync": "^2.2.0", + "verror": "^1.8.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -4666,11 +4825,6 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" - }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -5081,6 +5235,12 @@ "set-blocking": "^2.0.0" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5158,6 +5318,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/otpauth": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz", + "integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -5199,11 +5371,6 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, - "node_modules/parchment": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", - "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==" - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -5247,6 +5414,19 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-ldapauth": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/passport-ldapauth/-/passport-ldapauth-3.0.1.tgz", + "integrity": "sha512-TRRx3BHi8GC8MfCT9wmghjde/EGeKjll7zqHRRfGRxXbLcaDce2OftbQrFG7/AWaeFhR6zpZHtBQ/IkINdLVjQ==", + "license": "MIT", + "dependencies": { + "ldapauth-fork": "^5.0.1", + "passport-strategy": "^1.0.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/passport-local": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", @@ -5258,6 +5438,54 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-orcid": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/passport-orcid/-/passport-orcid-0.0.4.tgz", + "integrity": "sha512-swqn1PIQpzAz0qHXwlBlBaRFkfYXsXJ9o33T11QykCuuxR/UppbHGPgBOnrZaIf/Mytq6uYn8s5C4lAahaMYxQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "^1.5.0" + } + }, + "node_modules/passport-saml": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/passport-saml/-/passport-saml-3.2.4.tgz", + "integrity": "sha512-JSgkFXeaexLNQh1RrOvJAgjLnZzH/S3HbX/mWAk+i7aulnjqUe7WKnPl1NPnJWqP7Dqsv0I2Xm6KIFHkftk0HA==", + "deprecated": "For versions >= 4, please use scopped package @node-saml/passport-saml", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.7.6", + "debug": "^4.3.2", + "passport-strategy": "^1.0.0", + "xml-crypto": "^2.1.3", + "xml-encryption": "^2.0.0", + "xml2js": "^0.4.23", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -5540,6 +5768,14 @@ "node": ">=0.10.0" } }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -5631,20 +5867,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/quill": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", - "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", - "dependencies": { - "eventemitter3": "^5.0.1", - "lodash-es": "^4.17.21", - "parchment": "^3.0.0", - "quill-delta": "^5.1.0" - }, - "engines": { - "npm": ">=8.2.3" - } - }, "node_modules/quill-delta": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", @@ -5807,6 +6029,15 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -6635,6 +6866,12 @@ "node": ">= 0.8" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/umzug": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", @@ -6748,6 +6985,46 @@ "node": ">= 0.8" } }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -6928,6 +7205,64 @@ } } }, + "node_modules/xml-crypto": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-2.1.6.tgz", + "integrity": "sha512-jjvpO8vHNV8QFhW5bMypP+k4BjBqHe/HrpIwpPcdUnUTIJakSIuN96o3Sdah4tKu2z64kM/JHEH8iEHGCc6Gyw==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.7.9", + "xpath": "0.0.32" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/xml-encryption": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-2.0.0.tgz", + "integrity": "sha512-4Av83DdvAgUQQMfi/w8G01aJshbEZP9ewjmZMpS9t3H+OCZBDvyK4GJPnHGfWiXlArnPbYvR58JB9qF2x9Ds+Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.7.0", + "escape-html": "^1.0.3", + "xpath": "0.0.32" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", @@ -6936,6 +7271,15 @@ "node": ">=0.4.0" } }, + "node_modules/xpath": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", + "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 79f1954d9..97d11f4ea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -40,7 +40,6 @@ "passport-local": "^1.0.0", "passport-orcid": "^0.0.4", "passport-saml": "^3.2.4", - "passport-totp": "^0.0.2", "pg-promise": "^12.1.3", "quill-delta": "^5.1.0", "sequelize": "^6.32.1", @@ -55,10 +54,11 @@ "uuid": "^8.3.2", "winston": "^3.8.1", "winston-transport": "^4.5.0", - "yauzl": "^3.2.0" + "yauzl": "^3.2.0", + "otpauth": "^9.2.2" }, "devDependencies": { "cross-env": "^7.0.3", "jest": "^29.0.1" } -} +} \ No newline at end of file diff --git a/backend/utils/auth.js b/backend/utils/auth.js index 5c677a470..fedb69629 100644 --- a/backend/utils/auth.js +++ b/backend/utils/auth.js @@ -137,18 +137,3 @@ exports.generateOTP = function generateOTP() { const otp = Math.floor(100000 + Math.random() * 900000).toString(); return otp; } - -/** -* Generate a Base32 secret for TOTP -* @returns {string} 32-digit secret -*/ -exports.generateBase32Secret = function generateBase32Secret(length = 32) { - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; - let secret = ''; - for (let i = 0; i < length; i++) { - const idx = Math.floor(Math.random() * alphabet.length); - secret += alphabet[idx]; - } - return secret; -} - diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index c2d4e8ed5..4670320e5 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -15,7 +15,6 @@ const LocalStrategy = require("passport-local"); const OrcidStrategy = require('passport-orcid').Strategy; const LdapStrategy = require('passport-ldapauth'); const SamlStrategy = require('passport-saml').Strategy; -const TotpStrategy = require('passport-totp').Strategy; const {relevantFields} = require("../utils/auth"); const crypto = require("crypto"); const SequelizeStore = require('connect-session-sequelize')(session.Store); @@ -472,20 +471,6 @@ module.exports = class Server { } })); - /** - * TOTP verification strategy for 2FA. - * Used in /auth/2fa/totp/verify; expects req.user.totpSecret to be set. - */ - passport.use('totp-verify', new TotpStrategy( - function(user, done) { - if (!user || !user.totpSecret) { - return done(null, null); - } - // 30 second time step - return done(null, user.totpSecret, 30); - } - )); - // required to work -- defines strategy for storing user information passport.serializeUser(function (user, done) { done(null, user); diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index 630153e08..70e71b8bb 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -7,7 +7,8 @@ * @author Nils Dycke, Dennis Zyska */ const passport = require('passport'); -const { generateToken, decodeToken, generateOTP, generateBase32Secret } = require('../../utils/auth'); +const { TOTP, Secret } = require('otpauth'); +const { generateToken, decodeToken, generateOTP } = require('../../utils/auth'); /** * Route for user management @@ -921,70 +922,45 @@ The CARE Team` }); /** - * Verify TOTP and complete login (uses passport-totp) + * Verify TOTP and complete login */ - server.app.post('/auth/2fa/totp/verify', async function (req, res, next) { - const { token } = req.body; - - if (!token) { - return res.status(400).json({ message: "TOTP token is required." }); - } - - if (!req.session || !req.session.twoFactorPending) { - return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); - } - - if (req.session.twoFactorPending.method !== 'totp') { - return res.status(400).json({ message: "TOTP is not the selected 2FA method." }); - } - - try { - const { userId } = req.session.twoFactorPending; - const user = await server.db.models['user'].findOne({ where: { id: userId } }); - if (!user) { - delete req.session.twoFactorPending; - return res.status(400).json({ message: "User not found." }); + server.app.post('/auth/2fa/totp/verify', + async function (req, res, next) { + const token = String(req.body.token || '').replace(/\s/g, ''); + if (!token) { + return res.status(400).json({ message: 'TOTP token is required.' }); } - const methods = getTwoFactorMethods(user); - if (!methods.includes('totp') || !user.totpSecret) { - delete req.session.twoFactorPending; - return res.status(400).json({ message: "TOTP 2FA is not enabled for this user." }); + const pending = req.session?.twoFactorPending; + if (!pending?.userId) { + return res.status(401).json({ message: 'Invalid TOTP code' }); } - // Attach user and code for passport-totp verification - req.user = { id: user.id, totpSecret: user.totpSecret }; - req.body.code = String(token).replace(/\s/g, ''); + try { + const user = await server.db.models['user'].findOne({ + where: { id: pending.userId }, + raw: true, + }); + if (!user || !user.totpSecret) { + return res.status(401).json({ message: 'Invalid TOTP code' }); + } - passport.authenticate('totp-verify', async function (err, authedUser, info) { - if (err || !authedUser) { - return res.status(401).json({ message: "Invalid TOTP code." }); + const totp = new TOTP({ secret: user.totpSecret, digits: 6, period: 30 }); + if (totp.validate({ token, window: 1 }) === null) { + return res.status(401).json({ message: 'Invalid TOTP code' }); } delete req.session.twoFactorPending; - req.logIn(user, async function (err2) { - if (err2) { - return res.status(500).json({ message: "Failed to complete login." }); - } - - let transaction; - try { - transaction = await server.db.models['user'].sequelize.transaction(); - await server.db.models['user'].registerUserLogin(user.id, {transaction: transaction}); - await transaction.commit(); - } catch (e2) { - await transaction.rollback(); - } - + req.logIn(user, (err) => { + if (err) return next(err); return res.status(200).json({ user: user }); }); - })(req, res, next); - } catch (e) { - server.logger.error("Failed to verify TOTP: " + e); - return res.status(500).json({ message: "Internal server error" }); + } catch (err) { + return next(err); + } } - }); + ); /** * Get 2FA status for current user @@ -1158,7 +1134,6 @@ The CARE Team` /** * Initiate TOTP setup (authenticated user). - * Returns an otpauth URL that the frontend can convert to a QR code. */ server.app.post('/auth/2fa/totp/setup/initiate', ensureAuthenticated, async function (req, res) { const user = await server.db.models['user'].findOne({ where: { id: req.user.id }, raw: true }); @@ -1166,10 +1141,16 @@ The CARE Team` return res.status(404).json({ message: "User not found." }); } - const secretBase32 = generateBase32Secret(32); - const label = encodeURIComponent(`CARE (${user.userName})`); - const issuer = encodeURIComponent('CARE'); - const otpauthUrl = `otpauth://totp/${label}?secret=${secretBase32}&issuer=${issuer}`; + const secret = new Secret({ size: 20 }); + const secretBase32 = secret.base32; + const totp = new TOTP({ + issuer: 'CARE', + label: `CARE (${user.userName})`, + secret, + digits: 6, + period: 30, + }); + const otpauthUrl = totp.toString(); req.session.totpSetupPending = { secretBase32: secretBase32, @@ -1189,53 +1170,65 @@ The CARE Team` /** * Verify TOTP setup (authenticated user) and persist secret. - * Adds "totp" to twoFactorMethods on success. */ - server.app.post('/auth/2fa/totp/setup/verify', ensureAuthenticated, async function (req, res, next) { - const { token } = req.body; - - if (!token) { - return res.status(400).json({ message: "TOTP token is required." }); - } + server.app.post('/auth/2fa/totp/setup/verify', + ensureAuthenticated, + async function (req, res, next) { + const { token } = req.body; - if (!req.session || !req.session.totpSetupPending || !req.session.totpSetupPending.secretBase32) { - return res.status(400).json({ message: "No pending TOTP setup found. Please initiate setup again." }); - } + if (!token) { + return res.status(400).json({ message: "TOTP token is required." }); + } - const secretBase32 = req.session.totpSetupPending.secretBase32; + if (!req.session?.totpSetupPending?.secretBase32) { + return res.status(400).json({ + message: "No pending TOTP setup found." + }); + } - // Use passport-totp strategy for verification by attaching a temporary user - req.user = { id: req.user.id, totpSecret: secretBase32 }; - req.body.code = String(token).replace(/\s/g, ''); + const secretBase32 = req.session.totpSetupPending.secretBase32; + const originalUserId = req.user.id; - passport.authenticate('totp-verify', async function (err, authedUser, info) { - if (err || !authedUser) { + const totp = new TOTP({ secret: secretBase32, digits: 6, period: 30 }); + if (totp.validate({ token: String(token).trim(), window: 1 }) === null) { return res.status(401).json({ message: "Invalid TOTP code." }); } - const user = await server.db.models['user'].findOne({ where: { id: req.user.id }, raw: true }); - if (!user) { - return res.status(404).json({ message: "User not found." }); - } + try { + const dbUser = await server.db.models['user'].findOne({ + where: { id: originalUserId } + }); - const currentMethods = Array.isArray(user.twoFactorMethods) ? user.twoFactorMethods.slice() : []; - if (!currentMethods.includes('totp')) { - currentMethods.push('totp'); - } + if (!dbUser) { + return res.status(404).json({ message: "User not found." }); + } - await server.db.models['user'].update( - { totpSecret: secretBase32, twoFactorMethods: currentMethods }, - { where: { id: user.id } } - ); + const currentMethods = Array.isArray(dbUser.twoFactorMethods) + ? [...dbUser.twoFactorMethods] + : []; + if (!currentMethods.includes('totp')) { + currentMethods.push('totp'); + } - delete req.session.totpSetupPending; + await server.db.models['user'].update( + { + totpSecret: secretBase32, + twoFactorMethods: currentMethods + }, + { where: { id: dbUser.id } } + ); - return res.status(200).json({ - message: "TOTP has been successfully configured.", - twoFactorMethods: currentMethods, - }); - })(req, res, next); - }); + delete req.session.totpSetupPending; + + return res.status(200).json({ + message: "TOTP configured successfully.", + twoFactorMethods: currentMethods + }); + } catch (err) { + return next(err); + } + } + ); /** * ORCID login method From 81bee8a2752ea6219522ee65785255dba73b6d4d Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 15 Feb 2026 17:12:36 +0100 Subject: [PATCH 15/58] refactor: reorder route by category --- backend/webserver/Server.js | 2 +- backend/webserver/routes/auth.js | 148 +++++++++++++++++-------------- 2 files changed, 82 insertions(+), 68 deletions(-) diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 4670320e5..6adf1217e 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -204,7 +204,7 @@ module.exports = class Server { #loginManagement() { this.logger.debug("Initialize Routes for auth..."); - passport.use('local', new LocalStrategy(async (username, password, cb) => { + passport.use('local-login', new LocalStrategy(async (username, password, cb) => { const user = await this.db.models['user'].find(username); if (!user) { diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index 70e71b8bb..a46939a8b 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -10,7 +10,7 @@ const passport = require('passport'); const { TOTP, Secret } = require('otpauth'); const { generateToken, decodeToken, generateOTP } = require('../../utils/auth'); - /** +/** * Route for user management * @param {Server} server main server instance */ @@ -239,11 +239,15 @@ The CARE Team` }); } + // ========================================== + // AUTHENTICATION ROUTES + // ========================================== + /** * Login Procedure */ server.app.post('/auth/login', function (req, res, next) { - passport.authenticate('local', async function (err, user, info) { + passport.authenticate('local-login', async function (err, user, info) { if (err) { server.logger.error("Login failed: " + err); return res.status(500).send("Failed to login"); @@ -293,6 +297,70 @@ The CARE Team` })(req, res, next); }); + /** + * LDAP login method (JSON-based, similar to /auth/login) + */ + server.app.post('/auth/login/ldap', function (req, res, next) { + passport.authenticate('ldap-login', async function (err, user, info) { + if (err) { + server.logger.error("LDAP login failed: " + err); + return res.status(500).send("Failed to login"); + } + if (!user) { + return res.status(401).send(info || { message: "LDAP login failed." }); + } + + const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'json', redirectedFrom: req.query.redirectedFrom || '/dashboard' }); + if (handled) return; + + req.logIn(user, async function (err2) { + if (err2) return next(err2); + await server.db.models['user'].registerUserLogin(user.id); + return res.status(200).send({ user: user }); + }); + })(req, res, next); + }); + + /** + * ORCID login method + */ + server.app.get('/auth/login/orcid', passport.authenticate('orcid-login')); + + server.app.get('/auth/login/orcid/callback', + passport.authenticate('orcid-login', { failureRedirect: '/login?error=orcid-login-failed' }), + async function (req, res, next) { + const user = req.user; + const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect', redirectedFrom: '/dashboard' }); + if (handled) return; + + req.logIn(user, async function (err) { + if (err) return next(err); + await server.db.models['user'].registerUserLogin(user.id); + return res.redirect('/dashboard'); + }); + } + ); + + /** + * SAML login method + */ + server.app.get('/auth/login/saml', passport.authenticate('saml-login')); + + server.app.post('/auth/login/saml/callback', + passport.authenticate('saml-login', { failureRedirect: '/login?error=saml-login-failed' }), + async function (req, res, next) { + const user = req.user; + const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect', redirectedFrom: '/dashboard' }); + if (handled) return; + + req.logIn(user, async function (err) { + if (err) return next(err); + await server.db.models['user'].registerUserLogin(user.id); + return res.redirect('/dashboard'); + }); + } + ); + /** * Logout Procedure, no feedback needed since vuex also deletes the session */ @@ -320,6 +388,10 @@ The CARE Team` server.logger.debug(`req.user: ${JSON.stringify(req.user)}`); }); + // ========================================== + // REGISTRATION & EMAIL VERIFICATION ROUTES + // ========================================== + /** * Register Procedure */ @@ -766,6 +838,10 @@ The CARE Team` } } + // ========================================== + // 2FA VERIFICATION ROUTES (Login Flow) + // ========================================== + server.app.post('/auth/2fa/resend-otp', resendEmailOtpHandler); /** @@ -1130,8 +1206,6 @@ The CARE Team` } }); - - /** * Initiate TOTP setup (authenticated user). */ @@ -1230,69 +1304,9 @@ The CARE Team` } ); - /** - * ORCID login method - */ - server.app.get('/auth/login/orcid', passport.authenticate('orcid-login')); - - server.app.get('/auth/login/orcid/callback', - passport.authenticate('orcid-login', { failureRedirect: '/login?error=orcid-login-failed' }), - async function (req, res, next) { - const user = req.user; - const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect', redirectedFrom: '/dashboard' }); - if (handled) return; - - req.logIn(user, async function (err) { - if (err) return next(err); - await server.db.models['user'].registerUserLogin(user.id); - return res.redirect('/dashboard'); - }); - } - ); - - /** - * LDAP login method (JSON-based, similar to /auth/login) - */ - server.app.post('/auth/login/ldap', function (req, res, next) { - passport.authenticate('ldap-login', async function (err, user, info) { - if (err) { - server.logger.error("LDAP login failed: " + err); - return res.status(500).send("Failed to login"); - } - if (!user) { - return res.status(401).send(info || { message: "LDAP login failed." }); - } - - const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'json', redirectedFrom: req.query.redirectedFrom || '/dashboard' }); - if (handled) return; - - req.logIn(user, async function (err2) { - if (err2) return next(err2); - await server.db.models['user'].registerUserLogin(user.id); - return res.status(200).send({ user: user }); - }); - })(req, res, next); - }); - - /** - * SAML login method - */ - server.app.get('/auth/login/saml', passport.authenticate('saml-login')); - - server.app.post('/auth/login/saml/callback', - passport.authenticate('saml-login', { failureRedirect: '/login?error=saml-login-failed' }), - async function (req, res, next) { - const user = req.user; - const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect', redirectedFrom: '/dashboard' }); - if (handled) return; - - req.logIn(user, async function (err) { - if (err) return next(err); - await server.db.models['user'].registerUserLogin(user.id); - return res.redirect('/dashboard'); - }); - } - ); + // ========================================== + // ORCID LINKING ROUTES + // ========================================== /** * Initiate ORCID linking From 365eeea56b0598ea2ebfb47e425894c113a23519 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 15 Feb 2026 17:24:29 +0100 Subject: [PATCH 16/58] refactor: reorder routes by category --- backend/webserver/routes/auth.js | 454 ++++++++++++++++--------------- 1 file changed, 230 insertions(+), 224 deletions(-) diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index a46939a8b..c42023b3c 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -16,6 +16,10 @@ const { generateToken, decodeToken, generateOTP } = require('../../utils/auth'); */ module.exports = function (server) { + // ========================================== + // HELPER FUNCTIONS + // ========================================== + /** * Helper function to get the base URL from settings */ @@ -123,6 +127,80 @@ The CARE Team` ); } + /** + * Request OTP again for 2FA verification + * Called after password verification when user has 2FA enabled + * Uses session to track 2FA state + */ + async function resendEmailOtp(req, res) { + // Check if session has 2FA pending state + if (!req.session || !req.session.twoFactorPending) { + return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); + } + + // Must have selected email 2FA + if (req.session.twoFactorPending.method !== 'email') { + return res.status(400).json({ message: "Email 2FA is not the selected method. Please select email first." }); + } + + try { + const { userId } = req.session.twoFactorPending; + + // Get user details + const user = await server.db.models['user'].findOne({ + where: { id: userId } + }); + + const methods = getTwoFactorMethods(user); + + if (!user || !methods.includes('email')) { + // Clear invalid session state + delete req.session.twoFactorPending; + return res.status(400).json({ message: "Email 2FA is not enabled for this user." }); + } + + if (!user.email) { + return res.status(400).json({ message: "User email not found. Cannot send OTP." }); + } + + // Generate 6-digit OTP + const otp = generateOTP(); + const otpExpiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes expiry + + // Store OTP in user record + await server.db.models['user'].update( + { + twoFactorOtp: otp, + twoFactorOtpExpiresAt: otpExpiresAt + }, + { where: { id: user.id } } + ); + + // Send OTP via email + await server.sendMail( + user.email, + "CARE - Two-Factor Authentication Code", + `Hello ${user.userName}, + + Your two-factor authentication code is: ${otp} + + This code will expire in 10 minutes. If you didn't request this code, please ignore this email. + + Thanks, + The CARE Team` + ); + + return res.status(200).json({ + message: "OTP has been sent to your email address.", + expiresIn: 10 // minutes + }); + + } catch (error) { + server.logger.error("Failed to request OTP: " + error); + return res.status(500).json({ message: "Internal server error" }); + } + } + /** * Start 2FA if configured for the user. * - If exactly 1 method is configured: start it immediately (send email OTP if needed). @@ -764,91 +842,78 @@ The CARE Team` } }); + // ========================================== + // 2FA VERIFICATION ROUTES (Login Flow) + // ========================================== + /** - * Request OTP again for 2FA verification - * Called after password verification when user has 2FA enabled - * Uses session to track 2FA state + * Select a 2FA method when multiple are configured. + * If email is selected, the OTP will be sent after selection. */ - async function resendEmailOtpHandler(req, res) { - // Check if session has 2FA pending state + server.app.post('/auth/2fa/select', async function (req, res) { + const { method } = req.body; + if (!req.session || !req.session.twoFactorPending) { return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); } - // Must have selected email 2FA - if (req.session.twoFactorPending.method !== 'email') { - return res.status(400).json({ message: "Email 2FA is not the selected method. Please select email first." }); + const pending = req.session.twoFactorPending; + if (!method || !pending.methods || !Array.isArray(pending.methods)) { + return res.status(400).json({ message: "Missing 2FA method selection." }); } - - try { - const { userId } = req.session.twoFactorPending; - // Get user details - const user = await server.db.models['user'].findOne({ - where: { id: userId } - }); + if (!pending.methods.includes(method)) { + return res.status(400).json({ message: "Selected 2FA method is not enabled for this user." }); + } - const methods = getTwoFactorMethods(user); + // Load user for any required side effects (email OTP send / totp secret existence) + const userRecord = await server.db.models['user'].findOne({ + where: { id: pending.userId }, + raw: true, + }); - if (!user || !methods.includes('email')) { - // Clear invalid session state - delete req.session.twoFactorPending; - return res.status(400).json({ message: "Email 2FA is not enabled for this user." }); - } - - if (!user.email) { - return res.status(400).json({ message: "User email not found. Cannot send OTP." }); - } - - // Generate 6-digit OTP - const otp = generateOTP(); - const otpExpiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes expiry - - // Store OTP in user record - await server.db.models['user'].update( - { - twoFactorOtp: otp, - twoFactorOtpExpiresAt: otpExpiresAt - }, - { where: { id: user.id } } - ); - - // Send OTP via email - await server.sendMail( - user.email, - "CARE - Two-Factor Authentication Code", - `Hello ${user.userName}, + if (!userRecord) { + delete req.session.twoFactorPending; + return res.status(400).json({ message: "User not found." }); + } - Your two-factor authentication code is: ${otp} + pending.method = method; + req.session.twoFactorPending = pending; - This code will expire in 10 minutes. If you didn't request this code, please ignore this email. + try { + if (method === 'email') { + if (!userRecord.email) { + return res.status(400).json({ message: "Email address not found. Cannot use email 2FA." }); + } + await sendEmailOtp(userRecord); + req.session.save(() => { + return res.status(200).json({ requiresTwoFactor: true, method: 'email' }); + }); + return; + } - Thanks, - The CARE Team` - ); - - return res.status(200).json({ - message: "OTP has been sent to your email address.", - expiresIn: 10 // minutes - }); - - } catch (error) { - server.logger.error("Failed to request OTP: " + error); - return res.status(500).json({ message: "Internal server error" }); - } - } + if (method === 'totp') { + if (!userRecord.totpSecret) { + return res.status(400).json({ message: "TOTP is enabled but not configured (missing secret)." }); + } + req.session.save(() => { + return res.status(200).json({ requiresTwoFactor: true, method: 'totp' }); + }); + return; + } - // ========================================== - // 2FA VERIFICATION ROUTES (Login Flow) - // ========================================== - - server.app.post('/auth/2fa/resend-otp', resendEmailOtpHandler); + return res.status(400).json({ message: `Unsupported 2FA method: ${method}` }); + } catch (e) { + server.logger.error("Failed to apply 2FA selection: " + e); + return res.status(500).json({ message: "Failed to start selected 2FA method." }); + } + }); /** * Verify OTP and complete login * Uses session to track 2FA state */ - server.app.post('/auth/2fa/verify', async function (req, res) { + server.app.post('/auth/2fa/email/verify', async function (req, res) { const { otp } = req.body; if (!otp) { @@ -934,68 +999,7 @@ The CARE Team` } }); - /** - * Select a 2FA method when multiple are configured. - * If email is selected, the OTP will be sent after selection. - */ - server.app.post('/auth/2fa/select', async function (req, res) { - const { method } = req.body; - - if (!req.session || !req.session.twoFactorPending) { - return res.status(400).json({ message: "No pending 2FA verification found. Please login again." }); - } - - const pending = req.session.twoFactorPending; - if (!method || !pending.methods || !Array.isArray(pending.methods)) { - return res.status(400).json({ message: "Missing 2FA method selection." }); - } - - if (!pending.methods.includes(method)) { - return res.status(400).json({ message: "Selected 2FA method is not enabled for this user." }); - } - - // Load user for any required side effects (email OTP send / totp secret existence) - const userRecord = await server.db.models['user'].findOne({ - where: { id: pending.userId }, - raw: true, - }); - - if (!userRecord) { - delete req.session.twoFactorPending; - return res.status(400).json({ message: "User not found." }); - } - - pending.method = method; - req.session.twoFactorPending = pending; - - try { - if (method === 'email') { - if (!userRecord.email) { - return res.status(400).json({ message: "Email address not found. Cannot use email 2FA." }); - } - await sendEmailOtp(userRecord); - req.session.save(() => { - return res.status(200).json({ requiresTwoFactor: true, method: 'email' }); - }); - return; - } - - if (method === 'totp') { - if (!userRecord.totpSecret) { - return res.status(400).json({ message: "TOTP is enabled but not configured (missing secret)." }); - } - req.session.save(() => { - return res.status(200).json({ requiresTwoFactor: true, method: 'totp' }); - }); - return; - } - - return res.status(400).json({ message: `Unsupported 2FA method: ${method}` }); - } catch (e) { - server.logger.error("Failed to apply 2FA selection: " + e); - return res.status(500).json({ message: "Failed to start selected 2FA method." }); - } - }); + server.app.post('/auth/2fa/otp/resend', resendEmailOtp); /** * Verify TOTP and complete login @@ -1038,6 +1042,104 @@ The CARE Team` } ); + /** + * Initiate TOTP setup (authenticated user). + */ + server.app.post('/auth/2fa/totp/setup/initiate', ensureAuthenticated, async function (req, res) { + const user = await server.db.models['user'].findOne({ where: { id: req.user.id }, raw: true }); + if (!user) { + return res.status(404).json({ message: "User not found." }); + } + + const secret = new Secret({ size: 20 }); + const secretBase32 = secret.base32; + const totp = new TOTP({ + issuer: 'CARE', + label: `CARE (${user.userName})`, + secret, + digits: 6, + period: 30, + }); + const otpauthUrl = totp.toString(); + + req.session.totpSetupPending = { + secretBase32: secretBase32, + }; + + req.session.save((err) => { + if (err) { + server.logger.error("Failed to save session for TOTP setup: " + err); + return res.status(500).json({ message: "Failed to initiate TOTP setup." }); + } + return res.status(200).json({ + otpauthUrl: otpauthUrl, + secretBase32: secretBase32, + }); + }); + }); + + /** + * Verify TOTP setup (authenticated user) and persist secret. + */ + server.app.post('/auth/2fa/totp/setup/verify', + ensureAuthenticated, + async function (req, res, next) { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ message: "TOTP token is required." }); + } + + if (!req.session?.totpSetupPending?.secretBase32) { + return res.status(400).json({ + message: "No pending TOTP setup found." + }); + } + + const secretBase32 = req.session.totpSetupPending.secretBase32; + const originalUserId = req.user.id; + + const totp = new TOTP({ secret: secretBase32, digits: 6, period: 30 }); + if (totp.validate({ token: String(token).trim(), window: 1 }) === null) { + return res.status(401).json({ message: "Invalid TOTP code." }); + } + + try { + const dbUser = await server.db.models['user'].findOne({ + where: { id: originalUserId } + }); + + if (!dbUser) { + return res.status(404).json({ message: "User not found." }); + } + + const currentMethods = Array.isArray(dbUser.twoFactorMethods) + ? [...dbUser.twoFactorMethods] + : []; + if (!currentMethods.includes('totp')) { + currentMethods.push('totp'); + } + + await server.db.models['user'].update( + { + totpSecret: secretBase32, + twoFactorMethods: currentMethods + }, + { where: { id: dbUser.id } } + ); + + delete req.session.totpSetupPending; + + return res.status(200).json({ + message: "TOTP configured successfully.", + twoFactorMethods: currentMethods + }); + } catch (err) { + return next(err); + } + } + ); + /** * Get 2FA status for current user */ @@ -1206,103 +1308,7 @@ The CARE Team` } }); - /** - * Initiate TOTP setup (authenticated user). - */ - server.app.post('/auth/2fa/totp/setup/initiate', ensureAuthenticated, async function (req, res) { - const user = await server.db.models['user'].findOne({ where: { id: req.user.id }, raw: true }); - if (!user) { - return res.status(404).json({ message: "User not found." }); - } - - const secret = new Secret({ size: 20 }); - const secretBase32 = secret.base32; - const totp = new TOTP({ - issuer: 'CARE', - label: `CARE (${user.userName})`, - secret, - digits: 6, - period: 30, - }); - const otpauthUrl = totp.toString(); - - req.session.totpSetupPending = { - secretBase32: secretBase32, - }; - - req.session.save((err) => { - if (err) { - server.logger.error("Failed to save session for TOTP setup: " + err); - return res.status(500).json({ message: "Failed to initiate TOTP setup." }); - } - return res.status(200).json({ - otpauthUrl: otpauthUrl, - secretBase32: secretBase32, - }); - }); - }); - /** - * Verify TOTP setup (authenticated user) and persist secret. - */ - server.app.post('/auth/2fa/totp/setup/verify', - ensureAuthenticated, - async function (req, res, next) { - const { token } = req.body; - - if (!token) { - return res.status(400).json({ message: "TOTP token is required." }); - } - - if (!req.session?.totpSetupPending?.secretBase32) { - return res.status(400).json({ - message: "No pending TOTP setup found." - }); - } - - const secretBase32 = req.session.totpSetupPending.secretBase32; - const originalUserId = req.user.id; - - const totp = new TOTP({ secret: secretBase32, digits: 6, period: 30 }); - if (totp.validate({ token: String(token).trim(), window: 1 }) === null) { - return res.status(401).json({ message: "Invalid TOTP code." }); - } - - try { - const dbUser = await server.db.models['user'].findOne({ - where: { id: originalUserId } - }); - - if (!dbUser) { - return res.status(404).json({ message: "User not found." }); - } - - const currentMethods = Array.isArray(dbUser.twoFactorMethods) - ? [...dbUser.twoFactorMethods] - : []; - if (!currentMethods.includes('totp')) { - currentMethods.push('totp'); - } - - await server.db.models['user'].update( - { - totpSecret: secretBase32, - twoFactorMethods: currentMethods - }, - { where: { id: dbUser.id } } - ); - - delete req.session.totpSetupPending; - - return res.status(200).json({ - message: "TOTP configured successfully.", - twoFactorMethods: currentMethods - }); - } catch (err) { - return next(err); - } - } - ); // ========================================== // ORCID LINKING ROUTES From e78c0a994d7f36d73956e2a364be9e6b201d7189 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 15 Feb 2026 17:39:48 +0100 Subject: [PATCH 17/58] refactor: reuse sendEmailOtp method --- backend/webserver/routes/auth.js | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index c42023b3c..d6d72b8b2 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -163,32 +163,7 @@ The CARE Team` return res.status(400).json({ message: "User email not found. Cannot send OTP." }); } - // Generate 6-digit OTP - const otp = generateOTP(); - const otpExpiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes expiry - - // Store OTP in user record - await server.db.models['user'].update( - { - twoFactorOtp: otp, - twoFactorOtpExpiresAt: otpExpiresAt - }, - { where: { id: user.id } } - ); - - // Send OTP via email - await server.sendMail( - user.email, - "CARE - Two-Factor Authentication Code", - `Hello ${user.userName}, - - Your two-factor authentication code is: ${otp} - - This code will expire in 10 minutes. If you didn't request this code, please ignore this email. - - Thanks, - The CARE Team` - ); + await sendEmailOtp(user); return res.status(200).json({ message: "OTP has been sent to your email address.", From cf558f85b0c1446ade81f401e8cfb5f05a73e56a Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 15 Feb 2026 20:34:32 +0100 Subject: [PATCH 18/58] chore: update server routes --- frontend/src/auth/TwoFactorVerifyEmail.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/auth/TwoFactorVerifyEmail.vue b/frontend/src/auth/TwoFactorVerifyEmail.vue index 725df5b3b..34c770379 100644 --- a/frontend/src/auth/TwoFactorVerifyEmail.vue +++ b/frontend/src/auth/TwoFactorVerifyEmail.vue @@ -178,7 +178,7 @@ export default { try { const response = await axios.post( - getServerURL() + "/auth/2fa/verify", + getServerURL() + "/auth/2fa/email/verify", { otp: this.formData.otp }, { validateStatus: function (status) { @@ -222,7 +222,7 @@ export default { try { const response = await axios.post( - getServerURL() + "/auth/2fa/resend-otp", + getServerURL() + "/auth/2fa/otp/resend", {}, { validateStatus: function (status) { From b13f8c2f4602bcd79ad32e4f34c7c20801e99db6 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 15 Feb 2026 20:52:11 +0100 Subject: [PATCH 19/58] refactor: extract login logic in 2fa-related routes --- backend/webserver/routes/auth.js | 93 ++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 34 deletions(-) diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index d6d72b8b2..9e651a305 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -176,6 +176,59 @@ The CARE Team` } } + /** + * Complete 2FA verification and log user in + * Handles session save and login registration consistently for all 2FA methods + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {number} userId - User ID to log in + */ + async function completeTwoFactorLogin(req, res, userId) { + try { + const user = await server.db.models['user'].findOne({ + where: { id: userId }, + raw: true + }); + + if (!user) { + return res.status(404).json({ message: "User not found." }); + } + + // Clear 2FA pending state from session + delete req.session.twoFactorPending; + + // Complete login + req.logIn(user, async function (err) { + if (err) { + server.logger.error("Failed to log in user after 2FA: " + err); + return res.status(500).json({ message: "Failed to complete login." }); + } + + // Save session after login + req.session.save((saveErr) => { + if (saveErr) { + server.logger.error("Failed to save session after login: " + saveErr); + } + }); + + // Register user login + let transaction; + try { + transaction = await server.db.models['user'].sequelize.transaction(); + await server.db.models['user'].registerUserLogin(user.id, {transaction: transaction}); + await transaction.commit(); + } catch (e) { + await transaction.rollback(); + } + + return res.status(200).json({ user: user }); + }); + } catch (error) { + server.logger.error("Error completing 2FA login: " + error); + return res.status(500).json({ message: "Internal server error" }); + } + } + /** * Start 2FA if configured for the user. * - If exactly 1 method is configured: start it immediately (send email OTP if needed). @@ -906,12 +959,12 @@ The CARE Team` try { const { userId } = req.session.twoFactorPending; + // Get user details const user = await server.db.models['user'].findOne({ where: { id: userId } }); - if (!user) { // Clear invalid session state delete req.session.twoFactorPending; @@ -934,39 +987,14 @@ The CARE Team` return res.status(400).json({ message: "OTP has expired. Please request a new one." }); } - // OTP is valid - clear OTP and session state, then complete login + // OTP is valid - clear OTP from database await server.db.models['user'].update( { twoFactorOtp: null, twoFactorOtpExpiresAt: null }, { where: { id: user.id } } ); - // Clear 2FA pending state from session - delete req.session.twoFactorPending; - // Complete login - req.logIn(user, async function (err) { - if (err) { - return res.status(500).json({ message: "Failed to complete login." }); - } - - // Save session after login - req.session.save((saveErr) => { - if (saveErr) { - server.logger.error("Failed to save session after login: " + saveErr); - } - }); - - let transaction; - try { - transaction = await server.db.models['user'].sequelize.transaction(); - await server.db.models['user'].registerUserLogin(user.id, {transaction: transaction}); - await transaction.commit(); - } catch (e) { - await transaction.rollback(); - } - - return res.status(200).json({ user: user }); - }); + await completeTwoFactorLogin(req, res, userId); } catch (error) { server.logger.error("Failed to verify OTP: " + error); @@ -1005,12 +1033,9 @@ The CARE Team` return res.status(401).json({ message: 'Invalid TOTP code' }); } - delete req.session.twoFactorPending; - - req.logIn(user, (err) => { - if (err) return next(err); - return res.status(200).json({ user: user }); - }); + // Complete login + await completeTwoFactorLogin(req, res, pending.userId); + } catch (err) { return next(err); } From cbc21004333dbea7e21edc8184018bc9772c0d5d Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 15 Feb 2026 21:24:11 +0100 Subject: [PATCH 20/58] chore: remove unused parameter --- backend/webserver/routes/auth.js | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index 9e651a305..0d35ad270 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -238,7 +238,6 @@ The CARE Team` */ async function startTwoFactorIfConfigured(req, res, user, options = { mode: 'json' }) { const mode = options.mode || 'json'; // 'json' | 'redirect' - const redirectedFrom = options.redirectedFrom || '/dashboard'; const userRecord = await server.db.models['user'].findOne({ where: { id: user.id }, @@ -259,7 +258,6 @@ The CARE Team` userId: userRecord.id, methods: methods, method: null, - redirectedFrom: redirectedFrom, }; const respondJson = (payload) => { @@ -285,7 +283,7 @@ The CARE Team` // Multiple methods -> require selection if (methods.length > 1) { if (mode === 'redirect') { - return redirectTo(`/2fa/select?redirectedFrom=${encodeURIComponent(redirectedFrom)}`), true; + return redirectTo(`/2fa/select`), true; } respondJson({ requiresTwoFactor: true, @@ -319,10 +317,10 @@ The CARE Team` if (mode === 'redirect') { if (method === 'email') { - return redirectTo(`/2fa/verify/email?redirectedFrom=${encodeURIComponent(redirectedFrom)}`), true; + return redirectTo(`/2fa/verify/email`), true; } if (method === 'totp') { - return redirectTo(`/2fa/verify/totp?redirectedFrom=${encodeURIComponent(redirectedFrom)}`), true; + return redirectTo(`/2fa/verify/totp`), true; } return redirectTo(`/login?error=unsupported-2fa-method`), true; } @@ -376,7 +374,7 @@ The CARE Team` // Start 2FA if configured for this user - const twoFactorHandled = await startTwoFactorIfConfigured(req, res, user, { mode: 'json', redirectedFrom: req.query.redirectedFrom || '/dashboard' }); + const twoFactorHandled = await startTwoFactorIfConfigured(req, res, user, { mode: 'json' }); if (twoFactorHandled) { // 2FA response has been sent; stop normal login flow return; @@ -416,7 +414,7 @@ The CARE Team` return res.status(401).send(info || { message: "LDAP login failed." }); } - const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'json', redirectedFrom: req.query.redirectedFrom || '/dashboard' }); + const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'json' }); if (handled) return; req.logIn(user, async function (err2) { @@ -436,7 +434,7 @@ The CARE Team` passport.authenticate('orcid-login', { failureRedirect: '/login?error=orcid-login-failed' }), async function (req, res, next) { const user = req.user; - const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect', redirectedFrom: '/dashboard' }); + const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect'}); if (handled) return; req.logIn(user, async function (err) { @@ -456,7 +454,7 @@ The CARE Team` passport.authenticate('saml-login', { failureRedirect: '/login?error=saml-login-failed' }), async function (req, res, next) { const user = req.user; - const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect', redirectedFrom: '/dashboard' }); + const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect'}); if (handled) return; req.logIn(user, async function (err) { From cbac09cea6084d95c4026317f09b3c9cd2675d7a Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 15 Feb 2026 21:26:26 +0100 Subject: [PATCH 21/58] feat: add login buttons --- frontend/src/auth/Login.vue | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/frontend/src/auth/Login.vue b/frontend/src/auth/Login.vue index 181715550..27dca9bef 100644 --- a/frontend/src/auth/Login.vue +++ b/frontend/src/auth/Login.vue @@ -98,6 +98,30 @@ @click="$refs.forgotPasswordModal.open()" >Forgot Password?
+ +
+ +
+

+ Or sign in with +

+
+ + +
+
Date: Sun, 15 Feb 2026 21:47:14 +0100 Subject: [PATCH 22/58] chore: delete unused frontend page --- frontend/src/auth/TwoFactorVerifyLDAP.vue | 287 ---------------------- frontend/src/router.js | 6 - 2 files changed, 293 deletions(-) delete mode 100644 frontend/src/auth/TwoFactorVerifyLDAP.vue diff --git a/frontend/src/auth/TwoFactorVerifyLDAP.vue b/frontend/src/auth/TwoFactorVerifyLDAP.vue deleted file mode 100644 index 01e1a3d3b..000000000 --- a/frontend/src/auth/TwoFactorVerifyLDAP.vue +++ /dev/null @@ -1,287 +0,0 @@ - - - - - diff --git a/frontend/src/router.js b/frontend/src/router.js index 794265fb6..3410544dd 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -45,12 +45,6 @@ const routes = [ component: () => import("@/auth/TwoFactorVerifyTotp.vue"), meta: { requiresAuth: false } }, - { - path: '/2fa/ldap/verify', - name: '2fa-verify-ldap', - component: () => import("@/auth/TwoFactorVerifyLDAP.vue"), - meta: { requiresAuth: false } - }, { path: "/2fa/select", name: "2fa-select", From 5bc119e9620169788b1d8254ce7c9508fa510ad1 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Fri, 20 Feb 2026 10:07:42 +0100 Subject: [PATCH 23/58] chore: update orcid url --- .env | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env b/.env index 81c719b4b..86df7f30b 100644 --- a/.env +++ b/.env @@ -68,8 +68,8 @@ PG_STATS_TOP_N=10 # NOTE: For testing only. ORCID_CLIENT_ID=APP-F9HOSRPWAZTZKKRJ ORCID_CLIENT_SECRET=0fbb6db5-7164-49d6-8bae-0203adf8691d -ORCID_LINK_CALLBACK_URL=http://localhost:3000/auth/orcid/link/callback -ORCID_LOGIN_CALLBACK_URL=http://localhost:3000/auth/2fa/orcid/callback +ORCID_LINK_CALLBACK_URL=http://localhost:3001/auth/orcid/link/callback +ORCID_LOGIN_CALLBACK_URL=http://localhost:3001/auth/2fa/orcid/callback LDAP_SERVER_URL=ldap://localhost:389 LDAP_BIND_DN='cn=admin,dc=example,dc=org' From a59d4968dba2212d0bb8dcee936c0fdd5cc227cb Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Fri, 20 Feb 2026 13:55:04 +0100 Subject: [PATCH 24/58] chore: remove orcid linking routes and handler --- backend/webserver/Server.js | 36 -------------------------------- backend/webserver/routes/auth.js | 31 --------------------------- 2 files changed, 67 deletions(-) diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 6adf1217e..ce83c9e55 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -225,42 +225,6 @@ module.exports = class Server { }); })); - passport.use("orcid-link", new OrcidStrategy( - { - clientID: process.env.ORCID_CLIENT_ID, - clientSecret: process.env.ORCID_CLIENT_SECRET, - callbackURL: process.env.ORCID_LINK_CALLBACK_URL, - passReqToCallback: true, - }, - async (req, accessToken, refreshToken, params, profile, done) => { - try { - const orcidId = params.orcid; - - if (!req.user) { - return done(null, false, { - message: "User must be logged in", - }); - } - - // Save ORCID iD to user's account - await this.db.models["user"].update( - { orcidId: orcidId }, - { where: { id: req.user.id } }, - ); - - // Return user data - return done(null, { - userId: req.user.id, - orcidId: orcidId, - action: "link", - }); - } catch (error) { - return done(error); - } - }, - ), - ); - /** * ORCID login method (first factor). * diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index 0d35ad270..a60b8c1ee 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -1305,35 +1305,4 @@ The CARE Team` return res.status(500).json({ message: "Internal server error" }); } }); - - - - // ========================================== - // ORCID LINKING ROUTES - // ========================================== - - /** - * Initiate ORCID linking - * Passport handles: redirect to ORCID, state generation, etc. - */ - server.app.get('/auth/orcid/link', - ensureAuthenticated, - passport.authenticate('orcid-link') - ); - - /** - * ORCID link callback - * Passport handles: code exchange, token verification, etc. - */ - server.app.get('/auth/orcid/link/callback', - ensureAuthenticated, - passport.authenticate('orcid-link', { - session: false, - failureRedirect: '/dashboard?error=orcid-link-failed' - }), - async (req, res) => { - // req.user contains { userId, orcidId, action } - res.redirect('/dashboard?orcid-linked=success'); - } - ); } From f764cc03f52256d1f719a834a802af3a9ccf5566 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Fri, 20 Feb 2026 14:45:16 +0100 Subject: [PATCH 25/58] fix: remove email value constraint and set the full url for testing --- .../migrations/20260126144237-extend-user.js | 6 +++ backend/webserver/Server.js | 41 ++++++++----------- backend/webserver/routes/auth.js | 18 +++++--- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/backend/db/migrations/20260126144237-extend-user.js b/backend/db/migrations/20260126144237-extend-user.js index cb89be6de..b1fa3e287 100644 --- a/backend/db/migrations/20260126144237-extend-user.js +++ b/backend/db/migrations/20260126144237-extend-user.js @@ -61,6 +61,12 @@ const columns = [ /** @type {import('sequelize-cli').Migration} */ module.exports = { async up(queryInterface, Sequelize) { + await queryInterface.changeColumn("user", "email", { + type: Sequelize.STRING, + allowNull: true, + unique: true + }); + for (const column of columns) { await queryInterface.addColumn("user", column.name, { type: Sequelize[column.type], diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index ce83c9e55..0cdcb6fe9 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -238,47 +238,38 @@ module.exports = class Server { clientID: process.env.ORCID_CLIENT_ID, clientSecret: process.env.ORCID_CLIENT_SECRET, callbackURL: process.env.ORCID_LOGIN_CALLBACK_URL, - passReqToCallback : true + passReqToCallback: true }, - async (accessToken, refreshToken, params, profile, done) => { + async (req, accessToken, refreshToken, params, profile, done) => { try { const orcidId = params.orcid; - if (!orcidId) { - return done(null, false, { message: "Missing ORCID iD." }); - } + const fullName = params.name; - // 1) Try to find existing user by ORCID iD + // Try to find existing user let user = await this.db.models['user'].findOne({ - where: { orcidId: orcidId }, - raw: true, + where: { orcidId }, + raw: true }); - + if (!user) { - // 2) No linked user yet -> auto-create a new user - const email = - (Array.isArray(profile?.emails) && profile.emails[0]?.value) || - profile?.email || - null; - const firstName = profile?.name?.givenName || null; - const lastName = profile?.name?.familyName || null; - + const nameParts = fullName.trim().split(/\s+/); + const firstName = nameParts[0] || null; + const lastName = nameParts.length > 1 + ? nameParts.slice(1).join(' ') + : null; + const userData = { orcidId: orcidId, - email: email, firstName: firstName, lastName: lastName, - // External accounts are created with a random password by add() - // and get the default "user" role via hooks. - acceptTerms: true, - acceptStats: false, - emailVerified: !!email, // treat ORCID email as verified if present }; - + + // External accounts are created with a random password by add() + // and get the default "user" role via hooks. const created = await this.db.models['user'].add(userData, {}); // Ensure we pass a plain object into relevantFields user = created && created.get ? created.get({ plain: true }) : created; } - return done(null, relevantFields(user)); } catch (e) { return done(e); diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index a60b8c1ee..53ed134be 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -317,10 +317,10 @@ The CARE Team` if (mode === 'redirect') { if (method === 'email') { - return redirectTo(`/2fa/verify/email`), true; + return redirectTo(`http://localhost:3000/2fa/verify/email`), true; } if (method === 'totp') { - return redirectTo(`/2fa/verify/totp`), true; + return redirectTo(`http://localhost:3000/2fa/verify/totp`), true; } return redirectTo(`/login?error=unsupported-2fa-method`), true; } @@ -430,7 +430,7 @@ The CARE Team` */ server.app.get('/auth/login/orcid', passport.authenticate('orcid-login')); - server.app.get('/auth/login/orcid/callback', + server.app.get('/auth/2fa/orcid/callback', passport.authenticate('orcid-login', { failureRedirect: '/login?error=orcid-login-failed' }), async function (req, res, next) { const user = req.user; @@ -439,8 +439,16 @@ The CARE Team` req.logIn(user, async function (err) { if (err) return next(err); - await server.db.models['user'].registerUserLogin(user.id); - return res.redirect('/dashboard'); + let transaction; + try { + transaction = await server.db.models['user'].sequelize.transaction(); + await server.db.models['user'].registerUserLogin(user.id, {transaction}); + await transaction.commit(); + } catch (e) { + await transaction.rollback(); + } + // TODO: The url is for testing only. To be removed later. + return res.redirect('http://localhost:3000/dashboard'); }); } ); From 44457e360fd26f009b68af05529d8cb0016d9a16 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 22 Feb 2026 13:59:04 +0100 Subject: [PATCH 26/58] feat: add Login page for ldap user --- frontend/src/auth/LoginLdap.vue | 270 ++++++++++++++++++++++++++++++++ frontend/src/router.js | 6 + 2 files changed, 276 insertions(+) create mode 100644 frontend/src/auth/LoginLdap.vue diff --git a/frontend/src/auth/LoginLdap.vue b/frontend/src/auth/LoginLdap.vue new file mode 100644 index 000000000..89db1c394 --- /dev/null +++ b/frontend/src/auth/LoginLdap.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 3410544dd..b7c784a40 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -33,6 +33,12 @@ const routes = [ name: "login", meta: {requireAuth: false, hideTopbar: true, checkLogin: true} }, + { + path: "/login/ldap", + component: () => import("@/auth/LoginLdap.vue"), + name: "login-ldap", + meta: {requireAuth: false, hideTopbar: true, checkLogin: true} + }, { path: "/2fa/verify/email", name: "2fa-verify-email", From cc7a29313241f7180c01ed54b2716a0d72e03db6 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 22 Feb 2026 15:16:06 +0100 Subject: [PATCH 27/58] refactor: improve ldap login logic --- .env | 8 ++-- backend/webserver/Server.js | 76 +++++++++++++++---------------------- 2 files changed, 34 insertions(+), 50 deletions(-) diff --git a/.env b/.env index 86df7f30b..9ebbd13ba 100644 --- a/.env +++ b/.env @@ -72,10 +72,10 @@ ORCID_LINK_CALLBACK_URL=http://localhost:3001/auth/orcid/link/callback ORCID_LOGIN_CALLBACK_URL=http://localhost:3001/auth/2fa/orcid/callback LDAP_SERVER_URL=ldap://localhost:389 -LDAP_BIND_DN='cn=admin,dc=example,dc=org' -LDAP_BIND_CREDENTIALS='admin' -LDAP_SEARCH_BASE='ou=users,dc=example,dc=org' -LDAP_SEARCH_FILTER='(uid={{username}})' +LDAP_BIND_DN=cn=admin,dc=example,dc=org +LDAP_BIND_CREDENTIALS=admin +LDAP_SEARCH_BASE=ou=users,dc=example,dc=org +LDAP_SEARCH_FILTER=(uid={{username}}) # TODO: To be modified later SAML_ENTRY_POINT=test diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 0cdcb6fe9..d14e1032f 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -299,67 +299,51 @@ module.exports = class Server { bindDN: process.env.LDAP_BIND_DN, bindCredentials: process.env.LDAP_BIND_CREDENTIALS, searchBase: process.env.LDAP_SEARCH_BASE, - searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})', + searchFilter: process.env.LDAP_SEARCH_FILTER, }, passReqToCallback: true, }, async (req, ldapUser, done) => { try { - const username = (req.body && (req.body.username || req.body.userName)) ? (req.body.username || req.body.userName) : null; + const username = Array.isArray(ldapUser?.uid) ? ldapUser.uid[0] : ldapUser?.uid; + const ldapMail = ldapUser?.mail || ldapUser?.email; + const email = Array.isArray(ldapMail) ? ldapMail[0] : ldapMail; + + if (!username) return done(new Error('LDAP user entry is missing UID')); - // 1) Prefer explicit ldapUsername match let user = null; - if (username) { - user = await this.db.models['user'].findOne({ - where: { ldapUsername: username }, - raw: true, - }); - } - // 2) Fallback: try to match by email from LDAP profile, then bind ldapUsername - const ldapMail = ldapUser && (ldapUser.mail || ldapUser.email); - const ldapEmail = Array.isArray(ldapMail) ? ldapMail[0] : ldapMail; - if (!user && ldapEmail) { + // 1. First try to match by ldapUsername + user = await this.db.models['user'].findOne({ + where: { ldapUsername: username }, + raw: true, + }); + + // 2. Second, fall back to email -> In case the ldapUsername is changed + if (!user && email) { const existing = await this.db.models['user'].findOne({ - where: { email: ldapEmail }, + where: { email: email }, raw: true, }); + if (existing) { user = existing; - if (username && !existing.ldapUsername) { - await this.db.models['user'].update( - { ldapUsername: username }, - { where: { id: existing.id } } - ); - } + await this.db.models['user'].update( + { ldapUsername: username }, + { where: { id: existing.id } } + ); } } - + // 3. Auto create a new user if there is no match found at the previous steps if (!user) { - // 3) No linked user yet -> auto-create a new user - if (!ldapEmail) { - return done(null, false, { message: "LDAP account has no email; cannot create local user." }); - } - - const firstName = - (Array.isArray(ldapUser?.givenName) ? ldapUser.givenName[0] : ldapUser?.givenName) || - ldapUser?.cn || - null; - const lastName = - (Array.isArray(ldapUser?.sn) ? ldapUser.sn[0] : ldapUser?.sn) || - null; - - const userData = { - email: ldapEmail, - ldapUsername: username || (Array.isArray(ldapUser?.uid) ? ldapUser.uid[0] : ldapUser?.uid) || null, - firstName: firstName, - lastName: lastName, - acceptTerms: true, - acceptStats: false, - emailVerified: true, - }; - - const created = await this.db.models['user'].add(userData, {}); - user = created && created.get ? created.get({ plain: true }) : created; + const firstName = (Array.isArray(ldapUser?.givenName) ? ldapUser.givenName[0] : ldapUser?.givenName) || ldapUser?.cn || 'New'; + const lastName = (Array.isArray(ldapUser?.sn) ? ldapUser.sn[0] : ldapUser?.sn) || 'User'; + + user = await this.db.models['user'].add({ + ldapUsername: username, + email: email, + firstName, + lastName, + }); } return done(null, relevantFields(user)); From 7b845184fc13ac59acde678928a3c2da7f4721d9 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 22 Feb 2026 21:33:25 +0100 Subject: [PATCH 28/58] fix: fix user data leak between sessions by triggering page reload router.go(0) calls the browser's native history.go(0) under the hood, which forces a full page reload. This resets the vuex store back to its initial state, ensuring the correct user data is displayed after login. Without this, there is currently no explicit store reset on logout, which caused the previous user's data to persist into the next session. --- frontend/src/auth/Login.vue | 3 +-- frontend/src/auth/LoginLdap.vue | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/auth/Login.vue b/frontend/src/auth/Login.vue index 27dca9bef..b2899d832 100644 --- a/frontend/src/auth/Login.vue +++ b/frontend/src/auth/Login.vue @@ -283,10 +283,9 @@ export default { try { await this.login({username: this.formData.username, password: this.formData.password}) { - + // TODO: May need to figure out another way to fix old user data persisting issue. await this.$router.go(0); this.showError = false; - } } catch (error) { this.showError = true; diff --git a/frontend/src/auth/LoginLdap.vue b/frontend/src/auth/LoginLdap.vue index 89db1c394..6f43b1b03 100644 --- a/frontend/src/auth/LoginLdap.vue +++ b/frontend/src/auth/LoginLdap.vue @@ -154,6 +154,8 @@ export default { }); if (this.validForm) { await this.loginLdap(); + // TODO: May need to figure out another way to fix old user data persisting issue. + this.$router.go(0); } }, async loginLdap() { From 0a174be98055743fe1a9ab1f0425401a928b9976 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Mon, 23 Feb 2026 10:50:55 +0100 Subject: [PATCH 29/58] refactor: remove the transaction from login procedure as it is just time updating operation --- backend/webserver/routes/auth.js | 59 ++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index 53ed134be..e662f2e15 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -334,6 +334,41 @@ The CARE Team` return true; } + /** + * Finalizes the login process by establishing a session and recording the login activity. + * @param {Object} req - The Express request object. + * @param {Object} res - The Express response object. + * @param {Function} next - The Express next middleware function. + * @param {Object} user - The authenticated user object from Passport strategies. + * @param {Object} options - Configuration for the response. + * @param {string} options.mode - The response mode: 'json' for API responses or 'redirect' for browser-based flows. + * @param {string} [options.target] - The destination URL required if mode is set to 'redirect'. + * @returns {Promise} + */ + async function finalizeLogin(req, res, next, user, options = { mode: 'json' }) { + req.logIn(user, async (err) => { + if (err) return next(err); + + try { + // Update the last login timestamp in the database. + // Transaction is omitted here as a simple timestamp update is atomic in Sequelize. + await server.db.models['user'].registerUserLogin(user.id); + } catch (dbError) { + // Log the error but do not block the user from accessing the system. + console.error('[Auth] Failed to record login timestamp for user ID:', user.id, dbError); + } + + // Handle different response types based on the login source (e.g., AJAX vs OAuth Redirect). + if (options.mode === 'redirect') { + const redirectUrl = options.target || 'http://localhost:3000/dashboard'; + return res.redirect(redirectUrl); + } + + // Default to JSON response for LDAP or standard AJAX logins. + return res.status(200).send({ user }); + }); + } + function ensureAuthenticated(req, res, next) { if (req.isAuthenticated()) { return next(); @@ -375,29 +410,11 @@ The CARE Team` // Start 2FA if configured for this user const twoFactorHandled = await startTwoFactorIfConfigured(req, res, user, { mode: 'json' }); - if (twoFactorHandled) { - // 2FA response has been sent; stop normal login flow - return; - } + // 2FA response has been sent; stop normal login flow + if (twoFactorHandled) return; // No 2FA required, proceed with normal login - req.logIn(user, async function (err) { - if (err) { - return next(err); - } - - let transaction; - try { - transaction = await server.db.models['user'].sequelize.transaction(); - - await server.db.models['user'].registerUserLogin(user.id, {transaction: transaction}); - await transaction.commit(); - } catch (e) { - await transaction.rollback(); - } - - res.status(200).send({user: user}); - }); + return finalizeLogin(req, res, next, user, { mode:'json'}); })(req, res, next); }); From e18fa9e8366aa99e128a88599f35d97761802672 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Mon, 23 Feb 2026 10:54:22 +0100 Subject: [PATCH 30/58] refactor: replace repeated logic with generic method --- backend/webserver/routes/auth.js | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index e662f2e15..383009000 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -434,11 +434,7 @@ The CARE Team` const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'json' }); if (handled) return; - req.logIn(user, async function (err2) { - if (err2) return next(err2); - await server.db.models['user'].registerUserLogin(user.id); - return res.status(200).send({ user: user }); - }); + return finalizeLogin(req, res, next, user, { mode:'json'}); })(req, res, next); }); @@ -454,18 +450,9 @@ The CARE Team` const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect'}); if (handled) return; - req.logIn(user, async function (err) { - if (err) return next(err); - let transaction; - try { - transaction = await server.db.models['user'].sequelize.transaction(); - await server.db.models['user'].registerUserLogin(user.id, {transaction}); - await transaction.commit(); - } catch (e) { - await transaction.rollback(); - } - // TODO: The url is for testing only. To be removed later. - return res.redirect('http://localhost:3000/dashboard'); + return finalizeLogin(req, res, next, user, { + mode: 'redirect', + target: 'http://localhost:3000/dashboard' }); } ); @@ -482,10 +469,9 @@ The CARE Team` const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect'}); if (handled) return; - req.logIn(user, async function (err) { - if (err) return next(err); - await server.db.models['user'].registerUserLogin(user.id); - return res.redirect('/dashboard'); + return finalizeLogin(req, res, next, user, { + mode: 'redirect', + target: 'http://localhost:3000/dashboard' }); } ); From 6cb13e7c2a90608fdfebd7ce6b98f160bd689364 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Mon, 23 Feb 2026 11:36:53 +0100 Subject: [PATCH 31/58] chore: add TODO note --- backend/webserver/routes/auth.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index 383009000..2eb0dd125 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -230,6 +230,7 @@ The CARE Team` } /** + * TODO: Refactor this method * Start 2FA if configured for the user. * - If exactly 1 method is configured: start it immediately (send email OTP if needed). * - If multiple methods are configured: require the client to select one via /auth/2fa/select. @@ -443,6 +444,7 @@ The CARE Team` */ server.app.get('/auth/login/orcid', passport.authenticate('orcid-login')); + // TODO: Testing route server.app.get('/auth/2fa/orcid/callback', passport.authenticate('orcid-login', { failureRedirect: '/login?error=orcid-login-failed' }), async function (req, res, next) { From b25b3fe3ac91183301e3c302ae1a3b380be73b2f Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Mon, 23 Feb 2026 14:41:58 +0100 Subject: [PATCH 32/58] feat: create auth/2fa setting migration --- ...3125239-extend-setting-auth_methods_2fa.js | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 backend/db/migrations/20260223125239-extend-setting-auth_methods_2fa.js diff --git a/backend/db/migrations/20260223125239-extend-setting-auth_methods_2fa.js b/backend/db/migrations/20260223125239-extend-setting-auth_methods_2fa.js new file mode 100644 index 000000000..d64c821fc --- /dev/null +++ b/backend/db/migrations/20260223125239-extend-setting-auth_methods_2fa.js @@ -0,0 +1,130 @@ +"use strict"; + +const settings = [ + { + key: "system.auth.2fa.required", + type: "boolean", + value: false, + description: "Require all users to configure at least one 2FA method before using the platform" + }, + { + key: "system.auth.loginMethods.orcid.enabled", + type: "boolean", + value: false, + description: "Enable ORCID login flow" + }, + { + key: "system.auth.loginMethods.ldap.enabled", + type: "boolean", + value: false, + description: "Enable LDAP login flow" + }, + { + key: "system.auth.loginMethods.saml.enabled", + type: "boolean", + value: false, + description: "Enable SAML login flow" + }, + { + key: "system.auth.orcid.clientId", + type: "string", + value: "", + description: "ORCID OAuth client id (changes require a restart of the server)", + onlyAdmin: true + }, + { + key: "system.auth.orcid.clientSecret", + type: "string", + value: "", + description: "ORCID OAuth client secret (changes require a restart of the server)", + onlyAdmin: true + }, + { + key: "system.auth.orcid.callbackUrl", + type: "string", + value: "", + description: "ORCID callback URL (changes require a restart of the server)" + }, + { + key: "system.auth.orcid.sandbox", + type: "boolean", + value: true, + description: "Use ORCID sandbox mode (changes require a restart of the server)" + }, + { + key: "system.auth.ldap.url", + type: "string", + value: "", + description: "LDAP server URL (changes require a restart of the server)" + }, + { + key: "system.auth.ldap.bindDN", + type: "string", + value: "", + description: "LDAP bind DN (changes require a restart of the server)", + onlyAdmin: true + }, + { + key: "system.auth.ldap.bindCredentials", + type: "string", + value: "", + description: "LDAP bind password (changes require a restart of the server)", + onlyAdmin: true + }, + { + key: "system.auth.ldap.searchBase", + type: "string", + value: "", + description: "LDAP user search base (changes require a restart of the server)" + }, + { + key: "system.auth.ldap.searchFilter", + type: "string", + value: "(uid={{username}})", + description: "LDAP search filter template (changes require a restart of the server)" + }, + { + key: "system.auth.saml.entryPoint", + type: "string", + value: "", + description: "SAML identity provider entry point (changes require a restart of the server)" + }, + { + key: "system.auth.saml.issuer", + type: "string", + value: "", + description: "SAML service provider issuer (changes require a restart of the server)" + }, + { + key: "system.auth.saml.cert", + type: "text", + value: "", + description: "SAML identity provider certificate (changes require a restart of the server)", + onlyAdmin: true + }, + { + key: "system.auth.saml.callbackUrl", + type: "string", + value: "", + description: "SAML callback URL (changes require a restart of the server)" + }, +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.bulkInsert("setting", + settings.map((setting) => ({ + ...setting, + createdAt: new Date(), + updatedAt: new Date(), + })), + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete("setting", { + key: settings.map((setting) => setting.key) + }, {}); + } +}; From 369b7f22b884d5d9ce0dd43a2cfd716de5c072d3 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Mon, 23 Feb 2026 17:22:24 +0100 Subject: [PATCH 33/58] refactor: encapsulate each login strategy, refactor passport session handling method, and streamline loginManagement method --- backend/webserver/Server.js | 313 +++++++++++++++--------------------- 1 file changed, 128 insertions(+), 185 deletions(-) diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index d14e1032f..8347d0e4e 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -75,7 +75,9 @@ module.exports = class Server { this.logger.debug("Initializing Passport..."); this.app.use(bodyParser.urlencoded({extended: false})); this.app.use(bodyParser.json()); - this.#loginManagement(); + this.#loginManagement().catch((error) => { + this.logger.error("Failed to initialize login management: " + error); + }); this.app.use(passport.initialize()); this.app.use(passport.session()); @@ -201,224 +203,165 @@ module.exports = class Server { /** * Set the login management (routes and passport) */ - #loginManagement() { - this.logger.debug("Initialize Routes for auth..."); + async #loginManagement() { + this.logger.debug("Initializing Auth Strategies..."); - passport.use('local-login', new LocalStrategy(async (username, password, cb) => { + // 1. Setup Passport Session Handling + // Storing only the ID for efficiency and data freshness + passport.serializeUser((user, done) => { + done(null, user.id); + }); - const user = await this.db.models['user'].find(username); - if (!user) { - return cb(null, false, {message: 'Incorrect username or password.'}); + // When the session is authenticated, deserializeUser is called + // to retrieve the full user object from DB using the previously stored userId + passport.deserializeUser(async (id, done) => { + try { + const user = await this.db.models['user'].findByPk(id); + done(null, user ? relevantFields(user.get({ plain: true })) : null); + } catch (err) { + done(err); } + }); - crypto.pbkdf2(password, user.salt, 310000, 32, 'sha256', (err, hashedPassword) => { - if (err) { - return cb(err); - } + // 2. Initialize Strategies + await this.#setupLocalStrategy(); + await this.#setupOrcidStrategy(); + await this.#setupLdapStrategy(); + await this.#setupSamlStrategy(); + } - if (!crypto.timingSafeEqual(Buffer.from(user.passwordHash, 'hex'), hashedPassword)) { - return cb(null, false, {message: 'Incorrect username or password.'}); - } + /** + * Local Strategy Logic + */ + async #setupLocalStrategy() { + passport.use('local-login', new LocalStrategy(async (username, password, cb) => { + try { + const user = await this.db.models['user'].find(username); + if (!user) return cb(null, false, { message: 'Incorrect username or password.' }); - // filter row object, because not everything is the right information for website - return cb(null, relevantFields(user)); - }); + crypto.pbkdf2(password, user.salt, 310000, 32, 'sha256', (err, hashedPassword) => { + if (err) return cb(err); + if (!crypto.timingSafeEqual(Buffer.from(user.passwordHash, 'hex'), hashedPassword)) { + return cb(null, false, { message: 'Incorrect username or password.' }); + } + // filter row object, because not everything is the right information for website + return cb(null, relevantFields(user)); + }); + } catch (e) { return cb(e); } })); + } - /** - * ORCID login method (first factor). - * - * Behaviour: - * - If an existing user with this ORCID iD exists, log that user in. - * - Otherwise, automatically create a new local user account linked to this ORCID iD. - */ - passport.use("orcid-login", new OrcidStrategy( - { - sandbox: process.env.NODE_ENV !== 'production', - clientID: process.env.ORCID_CLIENT_ID, - clientSecret: process.env.ORCID_CLIENT_SECRET, - callbackURL: process.env.ORCID_LOGIN_CALLBACK_URL, - passReqToCallback: true - }, - async (req, accessToken, refreshToken, params, profile, done) => { - try { - const orcidId = params.orcid; - const fullName = params.name; + /** + * ORCID Strategy Logic + */ + async #setupOrcidStrategy() { + const config = { + sandbox: (await this.db.models['setting'].get("system.auth.orcid.sandbox")) === "true", + clientID: await this.db.models['setting'].get("system.auth.orcid.clientId"), + clientSecret: await this.db.models['setting'].get("system.auth.orcid.clientSecret"), + callbackURL: await this.db.models['setting'].get("system.auth.orcid.callbackUrl"), + passReqToCallback: true + }; - // Try to find existing user - let user = await this.db.models['user'].findOne({ - where: { orcidId }, - raw: true - }); - - if (!user) { - const nameParts = fullName.trim().split(/\s+/); - const firstName = nameParts[0] || null; - const lastName = nameParts.length > 1 - ? nameParts.slice(1).join(' ') - : null; - - const userData = { - orcidId: orcidId, - firstName: firstName, - lastName: lastName, - }; - - // External accounts are created with a random password by add() - // and get the default "user" role via hooks. - const created = await this.db.models['user'].add(userData, {}); - // Ensure we pass a plain object into relevantFields - user = created && created.get ? created.get({ plain: true }) : created; - } - return done(null, relevantFields(user)); - } catch (e) { - return done(e); + passport.use("orcid-login", new OrcidStrategy(config, async (req, accessToken, refreshToken, params, profile, done) => { + try { + const orcidId = params.orcid; + let user = await this.db.models['user'].findOne({ where: { orcidId }, raw: true }); + + if (!user) { + const nameParts = (params.name || "").trim().split(/\s+/); + user = await this.db.models['user'].add({ + orcidId, + firstName: nameParts[0] || null, + lastName: nameParts.length > 1 ? nameParts.slice(1).join(' ') : null, + }, {}); + if (user && user.get) user = user.get({ plain: true }); } - } - )); - - /** - * LDAP login method (first factor). - * - * Behaviour: - * - If an existing CARE user is linked via ldapUsername or email, log that user in. - * - Otherwise, automatically create a new CARE user account linked to this LDAP identity. - * - * Configuration is currently provided via environment variables. - * - * Required env vars: - * - LDAP_SERVER_URL - * - LDAP_BIND_DN - * - LDAP_BIND_CREDENTIALS - * - LDAP_SEARCH_BASE - * - LDAP_SEARCH_FILTER (optional; defaults to '(uid={{username}})') - */ - passport.use('ldap-login', new LdapStrategy({ - server: { - url: process.env.LDAP_SERVER_URL, - bindDN: process.env.LDAP_BIND_DN, - bindCredentials: process.env.LDAP_BIND_CREDENTIALS, - searchBase: process.env.LDAP_SEARCH_BASE, - searchFilter: process.env.LDAP_SEARCH_FILTER, - }, - passReqToCallback: true, - }, async (req, ldapUser, done) => { + return done(null, relevantFields(user)); + } catch (e) { return done(e); } + })); + } + + /** + * LDAP Strategy Logic + */ + async #setupLdapStrategy() { + const serverConfig = { + url: await this.db.models['setting'].get("system.auth.ldap.url"), + bindDN: await this.db.models['setting'].get("system.auth.ldap.bindDN"), + bindCredentials: await this.db.models['setting'].get("system.auth.ldap.bindCredentials"), + searchBase: await this.db.models['setting'].get("system.auth.ldap.searchBase"), + searchFilter: (await this.db.models['setting'].get("system.auth.ldap.searchFilter")) || "(uid={{username}})" + }; + + passport.use('ldap-login', new LdapStrategy({ server: serverConfig, passReqToCallback: true }, + async (req, ldapUser, done) => { try { const username = Array.isArray(ldapUser?.uid) ? ldapUser.uid[0] : ldapUser?.uid; - const ldapMail = ldapUser?.mail || ldapUser?.email; - const email = Array.isArray(ldapMail) ? ldapMail[0] : ldapMail; + const email = [].concat(ldapUser?.mail || ldapUser?.email || [])[0]; - if (!username) return done(new Error('LDAP user entry is missing UID')); + if (!username) return done(new Error('LDAP user missing UID')); - let user = null; - - // 1. First try to match by ldapUsername - user = await this.db.models['user'].findOne({ - where: { ldapUsername: username }, - raw: true, - }); + let user = await this.db.models['user'].findOne({ where: { ldapUsername: username }, raw: true }); - // 2. Second, fall back to email -> In case the ldapUsername is changed if (!user && email) { - const existing = await this.db.models['user'].findOne({ - where: { email: email }, - raw: true, - }); - - if (existing) { - user = existing; - await this.db.models['user'].update( - { ldapUsername: username }, - { where: { id: existing.id } } - ); + user = await this.db.models['user'].findOne({ where: { email }, raw: true }); + if (user) { + await this.db.models['user'].update({ ldapUsername: username }, { where: { id: user.id } }); + user.ldapUsername = username; // Update local reference } } - // 3. Auto create a new user if there is no match found at the previous steps - if (!user) { - const firstName = (Array.isArray(ldapUser?.givenName) ? ldapUser.givenName[0] : ldapUser?.givenName) || ldapUser?.cn || 'New'; - const lastName = (Array.isArray(ldapUser?.sn) ? ldapUser.sn[0] : ldapUser?.sn) || 'User'; + if (!user) { user = await this.db.models['user'].add({ ldapUsername: username, - email: email, - firstName, - lastName, + email, + firstName: [].concat(ldapUser?.givenName || ldapUser?.cn || 'New')[0], + lastName: [].concat(ldapUser?.sn || 'User')[0] }); + if (user && user.get) user = user.get({ plain: true }); } - return done(null, relevantFields(user)); - } catch (e) { - return done(e); - } + } catch (e) { return done(e); } })); + } - /** - * SAML login method (first factor). - * Configuration via environment variables for now. - * - * Required env vars: - * - SAML_ENTRY_POINT - * - SAML_ISSUER - * - SAML_CALLBACK_URL - * - SAML_CERT - */ - passport.use('saml-login', new SamlStrategy({ - entryPoint: process.env.SAML_ENTRY_POINT, - issuer: process.env.SAML_ISSUER, - callbackUrl: process.env.SAML_CALLBACK_URL, - cert: process.env.SAML_CERT, - }, async (profile, done) => { - try { - const nameId = profile && profile.nameID; - if (!nameId) { - return done(null, false, { message: "Missing SAML NameID." }); - } + /** + * SAML Strategy Logic + */ + async #setupSamlStrategy() { + const rawCert = await this.db.models['setting'].get("system.auth.saml.cert"); + const config = { + entryPoint: await this.db.models['setting'].get("system.auth.saml.entryPoint"), + issuer: await this.db.models['setting'].get("system.auth.saml.issuer"), + callbackUrl: await this.db.models['setting'].get("system.auth.saml.callbackUrl"), + cert: typeof rawCert === "string" ? rawCert.replace(/\\n/g, "\n") : rawCert + }; - // 1) Prefer explicit samlNameId match - let user = await this.db.models['user'].findOne({ - where: { samlNameId: nameId }, - raw: true, - }); + passport.use('saml-login', new SamlStrategy(config, async (profile, done) => { + try { + const nameId = profile?.nameID; + if (!nameId) return done(null, false, { message: "Missing SAML NameID." }); - // 2) Fallback: try to match by email and bind samlNameId - const email = profile && (profile.email || profile.mail || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']); - const samlEmail = Array.isArray(email) ? email[0] : email; + let user = await this.db.models['user'].findOne({ where: { samlNameId: nameId }, raw: true }); - if (!user && samlEmail) { - const existing = await this.db.models['user'].findOne({ - where: { email: samlEmail }, - raw: true, - }); - if (existing) { - user = existing; - if (!existing.samlNameId) { - await this.db.models['user'].update( - { samlNameId: nameId }, - { where: { id: existing.id } } - ); + if (!user) { + const emailAttr = profile.email || profile.mail || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']; + const email = Array.isArray(emailAttr) ? emailAttr[0] : emailAttr; + + if (email) { + user = await this.db.models['user'].findOne({ where: { email }, raw: true }); + if (user) { + await this.db.models['user'].update({ samlNameId: nameId }, { where: { id: user.id } }); + user.samlNameId = nameId; } } } - if (!user) { - return done(null, false, { message: "SAML account not linked to a CARE user." }); - } - + if (!user) return done(null, false, { message: "SAML account not linked." }); return done(null, relevantFields(user)); - } catch (e) { - return done(e); - } + } catch (e) { return done(e); } })); - - // required to work -- defines strategy for storing user information - passport.serializeUser(function (user, done) { - done(null, user); - }); - - // required to work -- defines strategy for loading user information - passport.deserializeUser(function (user, done) { - done(null, user); - }); } /** @@ -687,4 +630,4 @@ module.exports = class Server { } } -} \ No newline at end of file +} From b7492a1a7dc5b695259d456f22de6bd8018b1d91 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Tue, 24 Feb 2026 09:15:57 +0100 Subject: [PATCH 34/58] fix: restore to previous storage of full user object to avoid breaking user data retrieval --- backend/webserver/Server.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 8347d0e4e..52b0aa4c2 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -207,20 +207,16 @@ module.exports = class Server { this.logger.debug("Initializing Auth Strategies..."); // 1. Setup Passport Session Handling - // Storing only the ID for efficiency and data freshness - passport.serializeUser((user, done) => { - done(null, user.id); + // TODO: Could optimize by storing the userId only + // Storing the user object + passport.serializeUser(function (user, done) { + done(null, user); }); // When the session is authenticated, deserializeUser is called - // to retrieve the full user object from DB using the previously stored userId - passport.deserializeUser(async (id, done) => { - try { - const user = await this.db.models['user'].findByPk(id); - done(null, user ? relevantFields(user.get({ plain: true })) : null); - } catch (err) { - done(err); - } + // to retrieve the full user object from DB + passport.deserializeUser(function (user, done) { + done(null, user); }); // 2. Initialize Strategies From a2ade3f8c7adfbc8e26e8b4bc7e37095ca44ca8b Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Tue, 24 Feb 2026 09:30:41 +0100 Subject: [PATCH 35/58] feat: track auth provider status --- backend/webserver/Server.js | 244 +++++++++++++++++++++++++----------- 1 file changed, 170 insertions(+), 74 deletions(-) diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 52b0aa4c2..1c2d6083b 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -51,6 +51,12 @@ module.exports = class Server { this.availSockets = {}; this.services = {}; this.documentQueues = new Map(); + this.authProviderStatus = { + local: { ready: false, reason: "not-initialized" }, + orcid: { ready: false, reason: "not-initialized" }, + ldap: { ready: false, reason: "not-initialized" }, + saml: { ready: false, reason: "not-initialized" }, + }; // No Caching this.app.disable('etag'); @@ -245,119 +251,209 @@ module.exports = class Server { }); } catch (e) { return cb(e); } })); + this.authProviderStatus.local = { ready: true, reason: "ready" }; } /** * ORCID Strategy Logic */ async #setupOrcidStrategy() { + const enabled = (await this.db.models['setting'].get("system.auth.loginMethods.orcid.enabled")) === "true"; + if (!enabled) { + this.authProviderStatus.orcid = { ready: false, reason: "disabled" }; + return; + } + + const clientID = await this.db.models['setting'].get("system.auth.orcid.clientId"); + const clientSecret = await this.db.models['setting'].get("system.auth.orcid.clientSecret"); + const callbackURL = await this.db.models['setting'].get("system.auth.orcid.callbackUrl"); + const sandbox = (await this.db.models['setting'].get("system.auth.orcid.sandbox")) === "true"; + + const missing = []; + if (!clientID) missing.push("system.auth.orcid.clientId"); + if (!clientSecret) missing.push("system.auth.orcid.clientSecret"); + if (!callbackURL) missing.push("system.auth.orcid.callbackUrl"); + if (missing.length > 0) { + const reason = `missing-config:${missing.join(",")}`; + this.authProviderStatus.orcid = { ready: false, reason }; + this.logger.warn(`[Auth] ORCID login enabled but not ready (${reason}).`); + return; + } + const config = { - sandbox: (await this.db.models['setting'].get("system.auth.orcid.sandbox")) === "true", - clientID: await this.db.models['setting'].get("system.auth.orcid.clientId"), - clientSecret: await this.db.models['setting'].get("system.auth.orcid.clientSecret"), - callbackURL: await this.db.models['setting'].get("system.auth.orcid.callbackUrl"), + sandbox, + clientID, + clientSecret, + callbackURL, passReqToCallback: true }; - passport.use("orcid-login", new OrcidStrategy(config, async (req, accessToken, refreshToken, params, profile, done) => { - try { - const orcidId = params.orcid; - let user = await this.db.models['user'].findOne({ where: { orcidId }, raw: true }); - - if (!user) { - const nameParts = (params.name || "").trim().split(/\s+/); - user = await this.db.models['user'].add({ - orcidId, - firstName: nameParts[0] || null, - lastName: nameParts.length > 1 ? nameParts.slice(1).join(' ') : null, - }, {}); - if (user && user.get) user = user.get({ plain: true }); - } - return done(null, relevantFields(user)); - } catch (e) { return done(e); } - })); + try { + passport.use("orcid-login", new OrcidStrategy(config, async (req, accessToken, refreshToken, params, profile, done) => { + try { + const orcidId = params.orcid; + let user = await this.db.models['user'].findOne({ where: { orcidId }, raw: true }); + + if (!user) { + const nameParts = (params.name || "").trim().split(/\s+/); + user = await this.db.models['user'].add({ + orcidId, + firstName: nameParts[0] || null, + lastName: nameParts.length > 1 ? nameParts.slice(1).join(' ') : null, + }, {}); + if (user && user.get) user = user.get({ plain: true }); + } + return done(null, relevantFields(user)); + } catch (e) { return done(e); } + })); + this.authProviderStatus.orcid = { ready: true, reason: "ready" }; + } catch (e) { + this.authProviderStatus.orcid = { ready: false, reason: `init-error:${e.message}` }; + this.logger.error(`[Auth] Failed to initialize ORCID strategy: ${e.message}`); + } } /** * LDAP Strategy Logic */ async #setupLdapStrategy() { + const enabled = (await this.db.models['setting'].get("system.auth.loginMethods.ldap.enabled")) === "true"; + if (!enabled) { + this.authProviderStatus.ldap = { ready: false, reason: "disabled" }; + return; + } + + const url = await this.db.models['setting'].get("system.auth.ldap.url"); + const bindDN = await this.db.models['setting'].get("system.auth.ldap.bindDN"); + const bindCredentials = await this.db.models['setting'].get("system.auth.ldap.bindCredentials") || process.env.LDAP_BIND_CREDENTIALS; + const searchBase = await this.db.models['setting'].get("system.auth.ldap.searchBase") || process.env.LDAP_SEARCH_BASE; + const searchFilter = (await this.db.models['setting'].get("system.auth.ldap.searchFilter")) || process.env.LDAP_SEARCH_FILTER || "(uid={{username}})"; + + const missing = []; + if (!url) missing.push("system.auth.ldap.url"); + if (!bindDN) missing.push("system.auth.ldap.bindDN"); + if (!bindCredentials) missing.push("system.auth.ldap.bindCredentials"); + if (!searchBase) missing.push("system.auth.ldap.searchBase"); + if (missing.length > 0) { + const reason = `missing-config:${missing.join(",")}`; + this.authProviderStatus.ldap = { ready: false, reason }; + this.logger.warn(`[Auth] LDAP login enabled but not ready (${reason}).`); + return; + } + const serverConfig = { - url: await this.db.models['setting'].get("system.auth.ldap.url"), - bindDN: await this.db.models['setting'].get("system.auth.ldap.bindDN"), - bindCredentials: await this.db.models['setting'].get("system.auth.ldap.bindCredentials"), - searchBase: await this.db.models['setting'].get("system.auth.ldap.searchBase"), - searchFilter: (await this.db.models['setting'].get("system.auth.ldap.searchFilter")) || "(uid={{username}})" + url, + bindDN, + bindCredentials, + searchBase, + searchFilter }; - passport.use('ldap-login', new LdapStrategy({ server: serverConfig, passReqToCallback: true }, - async (req, ldapUser, done) => { - try { - const username = Array.isArray(ldapUser?.uid) ? ldapUser.uid[0] : ldapUser?.uid; - const email = [].concat(ldapUser?.mail || ldapUser?.email || [])[0]; + try { + passport.use('ldap-login', new LdapStrategy({ server: serverConfig, passReqToCallback: true }, + async (req, ldapUser, done) => { + try { + const username = Array.isArray(ldapUser?.uid) ? ldapUser.uid[0] : ldapUser?.uid; + const email = [].concat(ldapUser?.mail || ldapUser?.email || [])[0]; - if (!username) return done(new Error('LDAP user missing UID')); + if (!username) return done(new Error('LDAP user missing UID')); - let user = await this.db.models['user'].findOne({ where: { ldapUsername: username }, raw: true }); + let user = await this.db.models['user'].findOne({ where: { ldapUsername: username }, raw: true }); - if (!user && email) { - user = await this.db.models['user'].findOne({ where: { email }, raw: true }); - if (user) { - await this.db.models['user'].update({ ldapUsername: username }, { where: { id: user.id } }); - user.ldapUsername = username; // Update local reference + if (!user && email) { + user = await this.db.models['user'].findOne({ where: { email }, raw: true }); + if (user) { + await this.db.models['user'].update({ ldapUsername: username }, { where: { id: user.id } }); + user.ldapUsername = username; // Update local reference + } } - } - if (!user) { - user = await this.db.models['user'].add({ - ldapUsername: username, - email, - firstName: [].concat(ldapUser?.givenName || ldapUser?.cn || 'New')[0], - lastName: [].concat(ldapUser?.sn || 'User')[0] - }); - if (user && user.get) user = user.get({ plain: true }); - } - return done(null, relevantFields(user)); - } catch (e) { return done(e); } - })); + if (!user) { + user = await this.db.models['user'].add({ + ldapUsername: username, + email, + firstName: [].concat(ldapUser?.givenName || ldapUser?.cn || 'New')[0], + lastName: [].concat(ldapUser?.sn || 'User')[0] + }); + if (user && user.get) user = user.get({ plain: true }); + } + return done(null, relevantFields(user)); + } catch (e) { return done(e); } + })); + this.authProviderStatus.ldap = { ready: true, reason: "ready" }; + } catch (e) { + this.authProviderStatus.ldap = { ready: false, reason: `init-error:${e.message}` }; + this.logger.error(`[Auth] Failed to initialize LDAP strategy: ${e.message}`); + } } /** * SAML Strategy Logic */ async #setupSamlStrategy() { - const rawCert = await this.db.models['setting'].get("system.auth.saml.cert"); + const enabled = (await this.db.models['setting'].get("system.auth.loginMethods.saml.enabled")) === "true"; + if (!enabled) { + this.authProviderStatus.saml = { ready: false, reason: "disabled" }; + return; + } + + const entryPoint = await this.db.models['setting'].get("system.auth.saml.entryPoint"); + const issuer = await this.db.models['setting'].get("system.auth.saml.issuer"); + const callbackUrl = await this.db.models['setting'].get("system.auth.saml.callbackUrl"); + const rawCert = (await this.db.models['setting'].get("system.auth.saml.cert")); + const cert = typeof rawCert === "string" ? rawCert.replace(/\\n/g, "\n") : rawCert; + + const missing = []; + if (!entryPoint) missing.push("system.auth.saml.entryPoint"); + if (!issuer) missing.push("system.auth.saml.issuer"); + if (!callbackUrl) missing.push("system.auth.saml.callbackUrl"); + if (!cert) missing.push("system.auth.saml.cert"); + if (missing.length > 0) { + const reason = `missing-config:${missing.join(",")}`; + this.authProviderStatus.saml = { ready: false, reason }; + this.logger.warn(`[Auth] SAML login enabled but not ready (${reason}).`); + return; + } + const config = { - entryPoint: await this.db.models['setting'].get("system.auth.saml.entryPoint"), - issuer: await this.db.models['setting'].get("system.auth.saml.issuer"), - callbackUrl: await this.db.models['setting'].get("system.auth.saml.callbackUrl"), - cert: typeof rawCert === "string" ? rawCert.replace(/\\n/g, "\n") : rawCert + entryPoint, + issuer, + callbackUrl, + cert }; - passport.use('saml-login', new SamlStrategy(config, async (profile, done) => { - try { - const nameId = profile?.nameID; - if (!nameId) return done(null, false, { message: "Missing SAML NameID." }); + try { + passport.use('saml-login', new SamlStrategy(config, async (profile, done) => { + try { + const nameId = profile?.nameID; + if (!nameId) return done(null, false, { message: "Missing SAML NameID." }); - let user = await this.db.models['user'].findOne({ where: { samlNameId: nameId }, raw: true }); + let user = await this.db.models['user'].findOne({ where: { samlNameId: nameId }, raw: true }); - if (!user) { - const emailAttr = profile.email || profile.mail || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']; - const email = Array.isArray(emailAttr) ? emailAttr[0] : emailAttr; + if (!user) { + const emailAttr = profile.email || profile.mail || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']; + const email = Array.isArray(emailAttr) ? emailAttr[0] : emailAttr; - if (email) { - user = await this.db.models['user'].findOne({ where: { email }, raw: true }); - if (user) { - await this.db.models['user'].update({ samlNameId: nameId }, { where: { id: user.id } }); - user.samlNameId = nameId; + if (email) { + user = await this.db.models['user'].findOne({ where: { email }, raw: true }); + if (user) { + await this.db.models['user'].update({ samlNameId: nameId }, { where: { id: user.id } }); + user.samlNameId = nameId; + } } } - } - if (!user) return done(null, false, { message: "SAML account not linked." }); - return done(null, relevantFields(user)); - } catch (e) { return done(e); } - })); + if (!user) return done(null, false, { message: "SAML account not linked." }); + return done(null, relevantFields(user)); + } catch (e) { return done(e); } + })); + this.authProviderStatus.saml = { ready: true, reason: "ready" }; + } catch (e) { + this.authProviderStatus.saml = { ready: false, reason: `init-error:${e.message}` }; + this.logger.error(`[Auth] Failed to initialize SAML strategy: ${e.message}`); + } + } + } /** From 5caa59a0f108aa158a8bbaae233e44b96afc8157 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Tue, 24 Feb 2026 16:24:25 +0100 Subject: [PATCH 36/58] feat: add auth provider status guard to relevant routes --- backend/webserver/Server.js | 16 ++++++++ backend/webserver/routes/auth.js | 67 +++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 1c2d6083b..3a46045b0 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -454,6 +454,22 @@ module.exports = class Server { } } + /** + * Returns true if provider strategy is initialized and ready. + * @param {string} provider - local|orcid|ldap|saml + * @returns {boolean} + */ + isAuthProviderReady(provider) { + return !!this.authProviderStatus?.[provider]?.ready; + } + + /** + * Get detailed provider status. + * @param {string} provider + * @returns {{ready:boolean,reason:string}|{ready:false,reason:string}} + */ + getAuthProviderStatus(provider) { + return this.authProviderStatus?.[provider] || { ready: false, reason: "unknown-provider" }; } /** diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index 2eb0dd125..98ea04283 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -98,6 +98,10 @@ module.exports = function (server) { return []; } + async function isLoginMethodEnabled(method) { + return (await server.db.models['setting'].get(`system.auth.loginMethods.${method}.enabled`)) === "true"; + } + async function sendEmailOtp(userRecord) { if (!userRecord || !userRecord.email) { throw new Error("Email address missing for email 2FA."); @@ -361,7 +365,8 @@ The CARE Team` // Handle different response types based on the login source (e.g., AJAX vs OAuth Redirect). if (options.mode === 'redirect') { - const redirectUrl = options.target || 'http://localhost:3000/dashboard'; + const baseUrl = await getBaseUrl() + const redirectUrl = options.target || `${baseUrl}/dashboard`; return res.redirect(redirectUrl); } @@ -422,7 +427,17 @@ The CARE Team` /** * LDAP login method (JSON-based, similar to /auth/login) */ - server.app.post('/auth/login/ldap', function (req, res, next) { + server.app.post('/auth/login/ldap', async function (req, res, next) { + if (!(await isLoginMethodEnabled('ldap'))) { + return res.status(403).json({ message: "LDAP login is disabled by the administrator." }); + } + if (!server.isAuthProviderReady('ldap')) { + const status = server.getAuthProviderStatus('ldap'); + return res.status(503).json({ + message: "LDAP login is enabled but not fully configured. Please contact an administrator.", + reason: status.reason + }); + } passport.authenticate('ldap-login', async function (err, user, info) { if (err) { server.logger.error("LDAP login failed: " + err); @@ -442,11 +457,31 @@ The CARE Team` /** * ORCID login method */ - server.app.get('/auth/login/orcid', passport.authenticate('orcid-login')); + server.app.get('/auth/login/orcid', async function (req, res, next) { + if (!(await isLoginMethodEnabled('orcid'))) { + return res.redirect('/login?error=orcid-login-disabled'); + } + if (!server.isAuthProviderReady('orcid')) { + const status = server.getAuthProviderStatus('orcid'); + server.logger.warn(`[Auth] ORCID requested but provider is not ready (${status.reason}).`); + return res.redirect('/login?error=orcid-login-not-ready'); + } + return passport.authenticate('orcid-login')(req, res, next); + }); // TODO: Testing route server.app.get('/auth/2fa/orcid/callback', - passport.authenticate('orcid-login', { failureRedirect: '/login?error=orcid-login-failed' }), + async function (req, res, next) { + if (!(await isLoginMethodEnabled('orcid'))) { + return res.redirect('/login?error=orcid-login-disabled'); + } + if (!server.isAuthProviderReady('orcid')) { + const status = server.getAuthProviderStatus('orcid'); + server.logger.warn(`[Auth] ORCID callback hit but provider is not ready (${status.reason}).`); + return res.redirect('/login?error=orcid-login-not-ready'); + } + return passport.authenticate('orcid-login', { failureRedirect: '/login?error=orcid-login-failed' })(req, res, next); + }, async function (req, res, next) { const user = req.user; const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect'}); @@ -462,10 +497,30 @@ The CARE Team` /** * SAML login method */ - server.app.get('/auth/login/saml', passport.authenticate('saml-login')); + server.app.get('/auth/login/saml', async function (req, res, next) { + if (!(await isLoginMethodEnabled('saml'))) { + return res.redirect('/login?error=saml-login-disabled'); + } + if (!server.isAuthProviderReady('saml')) { + const status = server.getAuthProviderStatus('saml'); + server.logger.warn(`[Auth] SAML requested but provider is not ready (${status.reason}).`); + return res.redirect('/login?error=saml-login-not-ready'); + } + return passport.authenticate('saml-login')(req, res, next); + }); server.app.post('/auth/login/saml/callback', - passport.authenticate('saml-login', { failureRedirect: '/login?error=saml-login-failed' }), + async function (req, res, next) { + if (!(await isLoginMethodEnabled('saml'))) { + return res.redirect('/login?error=saml-login-disabled'); + } + if (!server.isAuthProviderReady('saml')) { + const status = server.getAuthProviderStatus('saml'); + server.logger.warn(`[Auth] SAML callback hit but provider is not ready (${status.reason}).`); + return res.redirect('/login?error=saml-login-not-ready'); + } + return passport.authenticate('saml-login', { failureRedirect: '/login?error=saml-login-failed' })(req, res, next); + }, async function (req, res, next) { const user = req.user; const handled = await startTwoFactorIfConfigured(req, res, user, { mode: 'redirect'}); From 3e2fcf98d1e35c5c6c1a3f8a63bf1265faa66631 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Wed, 25 Feb 2026 10:50:23 +0100 Subject: [PATCH 37/58] style: reformat html --- .../src/components/dashboard/settings/SettingItem.vue | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/dashboard/settings/SettingItem.vue b/frontend/src/components/dashboard/settings/SettingItem.vue index 4382324cd..1232a5cd9 100644 --- a/frontend/src/components/dashboard/settings/SettingItem.vue +++ b/frontend/src/components/dashboard/settings/SettingItem.vue @@ -28,9 +28,14 @@
- +

From 5ffb851c40c88e9810cd19a02dbf3ed9061f7df5 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Wed, 25 Feb 2026 10:52:02 +0100 Subject: [PATCH 38/58] feat: add text area to support other setting type --- .../src/components/dashboard/settings/SettingItem.vue | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/dashboard/settings/SettingItem.vue b/frontend/src/components/dashboard/settings/SettingItem.vue index 1232a5cd9..9db24ec23 100644 --- a/frontend/src/components/dashboard/settings/SettingItem.vue +++ b/frontend/src/components/dashboard/settings/SettingItem.vue @@ -23,7 +23,7 @@
{{ setting.key }}
{{ setting.description }}
-

+

@@ -37,8 +37,14 @@ type="checkbox" >
+ -

+
From e9a4469f267ea006ae7349ec85dc4dcf50943612 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Wed, 25 Feb 2026 11:01:25 +0100 Subject: [PATCH 39/58] feat: add third-party login buttons and add guard to ldap login page --- backend/webserver/routes/config.js | 3 +++ frontend/src/auth/Login.vue | 33 ++++++++++++++++++++++++------ frontend/src/auth/LoginLdap.vue | 8 ++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/backend/webserver/routes/config.js b/backend/webserver/routes/config.js index a86a1e996..363fdc493 100644 --- a/backend/webserver/routes/config.js +++ b/backend/webserver/routes/config.js @@ -28,6 +28,9 @@ module.exports = function (server) { "app.login.guest": await server.db.models['setting'].get("app.login.guest"), "app.login.forgotPassword": await server.db.models['setting'].get("app.login.forgotPassword"), "app.register.emailVerification": await server.db.models['setting'].get("app.register.emailVerification"), + "system.auth.loginMethods.orcid.enabled": await server.db.models['setting'].get("system.auth.loginMethods.orcid.enabled"), + "system.auth.loginMethods.ldap.enabled": await server.db.models['setting'].get("system.auth.loginMethods.ldap.enabled"), + "system.auth.loginMethods.saml.enabled": await server.db.models['setting'].get("system.auth.loginMethods.saml.enabled"), "app.landing.showDocs": await server.db.models['setting'].get("app.landing.showDocs"), "app.landing.linkDocs": await server.db.models['setting'].get("app.landing.linkDocs"), "app.landing.showProject": await server.db.models['setting'].get("app.landing.showProject"), diff --git a/frontend/src/auth/Login.vue b/frontend/src/auth/Login.vue index b2899d832..cd4497565 100644 --- a/frontend/src/auth/Login.vue +++ b/frontend/src/auth/Login.vue @@ -99,14 +99,15 @@ >Forgot Password?
-
+
-
+

Or sign in with

+
@@ -229,6 +239,18 @@ export default { showRegisterButton() { return window.config['app.register.enabled'] === 'true'; }, + showOrcidLogin() { + return window.config['system.auth.loginMethods.orcid.enabled'] === 'true'; + }, + showLdapLogin() { + return window.config['system.auth.loginMethods.ldap.enabled'] === 'true'; + }, + showSamlLogin() { + return window.config['system.auth.loginMethods.saml.enabled'] === 'true'; + }, + showExternalLoginOptions() { + return this.showOrcidLogin || this.showLdapLogin || this.showSamlLogin; + }, validUsername() { return this.formData.username !== ""; }, @@ -369,19 +391,18 @@ export default { await this.$router.push(this.$route.query.redirectedFrom || '/dashboard') } }, - loginWithOrcid() { - // Redirect to backend ORCID login endpoint window.location.href = getServerURL() + "/auth/login/orcid"; }, - + loginWithSaml() { + window.location.href = getServerURL() + "/auth/login/saml"; + }, toLdapLogin() { this.$router.push({ name: "login-ldap", query: { redirectedFrom: this.$route.query.redirectedFrom }, }); }, - showEmailVerificationModal(email) { this.$refs.emailVerificationModal.open(email); }, diff --git a/frontend/src/auth/LoginLdap.vue b/frontend/src/auth/LoginLdap.vue index 6f43b1b03..f04650a88 100644 --- a/frontend/src/auth/LoginLdap.vue +++ b/frontend/src/auth/LoginLdap.vue @@ -144,6 +144,14 @@ export default { Object.keys(this.formData).map((key) => [key, false]), ); }, + mounted() { + if (window.config["system.auth.loginMethods.ldap.enabled"] !== "true") { + this.$router.replace({ + name: "login", + query: { redirectedFrom: this.$route.query.redirectedFrom }, + }); + } + }, methods: { checkVal(key) { this.validity[key] = true; From 020f54a344071f9a3fba2d844a5ffc4e7f27e532 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Thu, 26 Feb 2026 10:54:45 +0100 Subject: [PATCH 40/58] feat: add enforced prop and add relevant checks in the 2fa enforcement flow --- frontend/src/auth/TwoFactorSettingsModal.vue | 90 +++++++++++++++++--- 1 file changed, 80 insertions(+), 10 deletions(-) diff --git a/frontend/src/auth/TwoFactorSettingsModal.vue b/frontend/src/auth/TwoFactorSettingsModal.vue index 8d78d7fcd..e28ec4db5 100644 --- a/frontend/src/auth/TwoFactorSettingsModal.vue +++ b/frontend/src/auth/TwoFactorSettingsModal.vue @@ -3,7 +3,10 @@ ref="modal" size="lg" name="TwoFactorSettingsModal" - @hide="resetForm" + :remove-close="enforced && enabledMethods.length === 0" + :disable-keyboard="enforced && enabledMethods.length === 0" + @show="handleModalShow" + @hide="handleModalHide" >