Skip to content

Commit 43761e7

Browse files
authored
Merge pull request #247 from aliansoftwareteam/feat/2fa-totp-phase1
feat(auth): add opt-in TOTP two-factor authentication (Phase 1)
2 parents 2bd980f + f95fa29 commit 43761e7

19 files changed

Lines changed: 1287 additions & 105 deletions

File tree

.claude/test-cases/TwoFactorAuth.md

Lines changed: 116 additions & 0 deletions
Large diffs are not rendered by default.

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ JWT_SECRET="your_jwt_secret_here" # REQUIRED
2727
JWT_EXP="24h"
2828
JWT_ALGORITHM="HS256"
2929

30+
# Two-factor auth (TOTP) — all OPTIONAL. 2FA works without them (safe values are
31+
# derived from JWT_SECRET), but set dedicated secrets in production so rotating
32+
# JWT_SECRET doesn't break stored 2FA secrets or in-flight login second-steps.
33+
TWO_FACTOR_ENC_KEY="" # key material for encrypting TOTP secrets at rest
34+
TWO_FACTOR_TEMP_SECRET="" # signs the short-lived login second-step (temp) token
35+
TWO_FACTOR_ISSUER="AlianHub" # label shown in the user's authenticator app
36+
3037

3138
# How long (in seconds) the per-(user, company) membership lookup is cached
3239
# after a successful authenticated request. Lower values pick up membership

Config/setMiddleware.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,13 @@ const verifyJWTTokenWithCRoute = [
161161
// unauthenticated (trusted body userData) — now JWT-protected; the
162162
// PAT branch in jwt.js blocks PATs from token management except /me.
163163
'/api/v2/api-tokens',
164+
// Two-factor auth management (Phase 1). JWT + companyId required so req.uid
165+
// is populated for the handlers. /api/v2/auth/2fa/validate is intentionally
166+
// NOT here — it is public (the user is mid-login, holding only a tempToken).
167+
'/api/v2/auth/2fa/status',
168+
'/api/v2/auth/2fa/setup',
169+
'/api/v2/auth/2fa/verify',
170+
'/api/v2/auth/2fa/disable',
164171
];
165172
const verifyJWTToken = [
166173
"/api/v2/company/delete",

Modules/Auth/controller.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ module.exports = {
88
...require('./controller/register'),
99
...require('./controller/loginSession'),
1010
...require('./controller/password'),
11+
...require('./controller/twoFactor'),
1112
};

Modules/Auth/controller/authHelpers.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,10 @@ const verifyLocalAuth = async (reqData, cb) => {
581581

582582
cb({
583583
status: true,
584-
data: { ...reqData, _id: resData._id },
584+
// twoFactorEnabled tells loginAuth whether to gate this login behind
585+
// the TOTP second-step. Password login only (Phase 1); the OAuth
586+
// paths don't set it, so they are unaffected.
587+
data: { ...reqData, _id: resData._id, twoFactorEnabled: !!(resData.twoFactor && resData.twoFactor.enabled) },
585588
message: "User Login Successfully",
586589
});
587590
} catch (error) {

Modules/Auth/controller/loginSession.js

Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const { updateUserFun } = require("../../Users/controller.js");
1515

1616

1717
const { addAndRemoveUserInMongodbNotificationCount, generateTokenV2Fun, verifyAuth } = require('./authHelpers');
18+
const twoFactorRules = require('../helpers/twoFactorRules');
1819
exports.manageAttempt = (req, res) => {
1920
const forwarded = req?.headers['x-forwarded-for'] || req.ip;
2021
const clientIp = forwarded ? forwarded?.split(',')[0] : req?.connection?.remoteAddress;
@@ -68,6 +69,51 @@ exports.removeUserNotification = (req,res) => {
6869
* Rejects with an error message if any issues occur during the Process.
6970
*/
7071

72+
/**
73+
* Issue a real session for `uid`: create the session row, mint the access
74+
* token, set the auth cookies, and respond — the canonical post-auth path.
75+
* Shared by password login and the 2FA second-step (/api/v2/auth/2fa/validate)
76+
* so both produce an identical session. On failure it sets
77+
* req.errorMessageObject and calls next() (the route's manageAttempt handler
78+
* returns the error). Behaviour is unchanged from the previous inline block.
79+
*/
80+
const finalizeSession = (req, res, uid, next) => {
81+
const forwarded = req?.headers['x-forwarded-for'] || req.ip;
82+
const clientIp = forwarded ? forwarded?.split(',')[0] : req?.connection?.remoteAddress;
83+
sesstionCtr.insertSessionFun({userId: uid}, req.headers['user-agent'] || "", clientIp, (sData) => {
84+
if (!(sData && sData.status)) {
85+
req.errorMessageObject = {message: "unauthorize user"};
86+
next();
87+
return;
88+
}
89+
generateTokenV2Fun(uid, sData.data.refreshToken, (gData) => {
90+
if (!(gData && gData.status)) {
91+
req.errorMessageObject = gData;
92+
next();
93+
return;
94+
}
95+
// TODO(P1-SEC-09): see matching comment near login.
96+
// Set httpOnly: true once the frontend no longer
97+
// reads these cookies with js-cookie.
98+
const setCookie = {
99+
maxAge: serviceCtr.convertToSeconds(process.env.JWT_EXP)*1000,
100+
httpOnly: false,
101+
secure: config.NODE_ENV === "production",
102+
sameSite: config.NODE_ENV === "production" ? "Strict" : "Lax",
103+
domain: process.env.NODE_ENV === "production" ? req.hostname : undefined,
104+
};
105+
res.cookie("refreshToken", sData.data.refreshToken, { ...setCookie, maxAge: Number(process.env.SESSIONEXPIREDTIME || 172800)*1000 });
106+
res.cookie("accessToken", gData.token, { ...setCookie });
107+
res.status(200).json({
108+
uid: uid,
109+
refreshToken: sData.data.refreshToken,
110+
accessToken: gData.token
111+
});
112+
});
113+
});
114+
};
115+
exports.finalizeSession = finalizeSession;
116+
71117
exports.loginAuth = (req, res, next) => {
72118
try {
73119
verifyAuth(req.body, (lUserRes) => {
@@ -79,39 +125,23 @@ exports.loginAuth = (req, res, next) => {
79125
});
80126
return;
81127
}
82-
const forwarded = req?.headers['x-forwarded-for'] || req.ip;
83-
const clientIp = forwarded ? forwarded?.split(',')[0] : req?.connection?.remoteAddress;
84-
sesstionCtr.insertSessionFun({userId: lUserRes.data._id}, req.headers['user-agent'] || "", clientIp, (sData) => {
85-
if (!(sData && sData.status)) {
86-
req.errorMessageObject = {message: "unauthorize user"};
87-
next();
88-
return;
89-
}
90-
generateTokenV2Fun(lUserRes.data._id, sData.data.refreshToken, (gData) => {
91-
if (!(gData && gData.status)) {
92-
req.errorMessageObject = gData;
93-
next();
94-
return;
95-
}
96-
// TODO(P1-SEC-09): see matching comment near login.
97-
// Set httpOnly: true once the frontend no longer
98-
// reads these cookies with js-cookie.
99-
const setCookie = {
100-
maxAge: serviceCtr.convertToSeconds(process.env.JWT_EXP)*1000,
101-
httpOnly: false,
102-
secure: config.NODE_ENV === "production",
103-
sameSite: config.NODE_ENV === "production" ? "Strict" : "Lax",
104-
domain: process.env.NODE_ENV === "production" ? req.hostname : undefined,
105-
};
106-
res.cookie("refreshToken", sData.data.refreshToken, { ...setCookie, maxAge: Number(process.env.SESSIONEXPIREDTIME || 172800)*1000 });
107-
res.cookie("accessToken", gData.token, { ...setCookie });
108-
res.status(200).json({
109-
uid: lUserRes.data._id,
110-
refreshToken: sData.data.refreshToken,
111-
accessToken: gData.token
112-
});
128+
// 2FA gate (password login only, Phase 1): if the account has
129+
// TOTP enabled, do NOT create a session here. Return a short
130+
// lived tempToken (signed with a separate secret) that the
131+
// client must exchange at /api/v2/auth/2fa/validate with a TOTP
132+
// or recovery code. Accounts without 2FA take the path below,
133+
// unchanged.
134+
if (lUserRes.data && lUserRes.data.twoFactorEnabled) {
135+
const tempToken = twoFactorRules.issueTempToken(lUserRes.data._id);
136+
res.status(200).json({
137+
status: true,
138+
twoFactorRequired: true,
139+
tempToken,
140+
uid: lUserRes.data._id
113141
});
114-
});
142+
return;
143+
}
144+
finalizeSession(req, res, lUserRes.data._id, next);
115145
return;
116146
}
117147
req.errorMessageObject = {message: lUserRes.message};
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Two-factor auth (TOTP) — Phase 1 controller. Opt-in, password-login only.
2+
// Endpoints:
3+
// POST /api/v2/auth/2fa/setup (authed) → otpauth URL + QR, stores a PENDING secret
4+
// POST /api/v2/auth/2fa/verify (authed) { code } → enable + return recovery codes once
5+
// POST /api/v2/auth/2fa/disable (authed) { code } → clear 2FA (needs a valid TOTP/recovery code)
6+
// POST /api/v2/auth/2fa/validate (public) { tempToken, code } → exchange for a real session
7+
// The secret is AES-encrypted at rest; recovery codes are bcrypt-hashed and
8+
// single-use. Nothing secret is ever returned to the client except the one-time
9+
// manual-entry key (setup) and the one-time recovery codes (verify).
10+
const mongoose = require('mongoose');
11+
const QRCode = require('qrcode');
12+
const mongoC = require('../../../utils/mongo-handler/mongoQueries');
13+
const { dbCollections } = require('../../../Config/collections');
14+
const logger = require('../../../Config/loggerConfig');
15+
const rules = require('../helpers/twoFactorRules');
16+
const loginSessionCtrl = require('./loginSession');
17+
18+
// ── DB helpers (userAuth lives in the global DB, keyed by _id) ───────────
19+
const findUserAuthById = (uid) => mongoC.MongoDbCrudOpration(
20+
dbCollections.GLOBAL,
21+
{ type: dbCollections.USER_AUTH, data: [{ _id: new mongoose.Types.ObjectId(uid) }] },
22+
'findOne',
23+
);
24+
// twoFactor is a Mixed object — write it wholesale to avoid partial-update pitfalls.
25+
const setTwoFactor = (uid, twoFactor) => mongoC.MongoDbCrudOpration(
26+
dbCollections.GLOBAL,
27+
{ type: dbCollections.USER_AUTH, data: [{ _id: new mongoose.Types.ObjectId(uid) }, { $set: { twoFactor } }] },
28+
'updateOne',
29+
);
30+
31+
// Index of the first matching (still-unused) recovery code, or -1.
32+
const findRecoveryMatch = async (code, hashes) => {
33+
if (!code || !Array.isArray(hashes)) return -1;
34+
for (let i = 0; i < hashes.length; i += 1) {
35+
// eslint-disable-next-line no-await-in-loop
36+
if (await rules.verifyRecoveryCode(code, hashes[i])) return i;
37+
}
38+
return -1;
39+
};
40+
41+
// GET /api/v2/auth/2fa/status (authed) — is 2FA enabled for the logged-in user?
42+
exports.twoFaStatus = async (req, res) => {
43+
try {
44+
const user = await findUserAuthById(req.uid);
45+
return res.status(200).json({ status: true, data: { enabled: !!(user && user.twoFactor && user.twoFactor.enabled) } });
46+
} catch (error) {
47+
logger.error(`2FA status error: ${error?.message || error}`);
48+
return res.status(400).json({ status: false, message: error?.message || 'Failed to read 2FA status' });
49+
}
50+
};
51+
52+
// POST /api/v2/auth/2fa/setup (authed) — start enrollment.
53+
exports.twoFaSetup = async (req, res) => {
54+
try {
55+
const uid = req.uid;
56+
const user = await findUserAuthById(uid);
57+
if (!user?._id) return res.status(404).json({ status: false, message: 'User not found' });
58+
if (user.twoFactor && user.twoFactor.enabled) {
59+
return res.status(409).json({ status: false, message: 'Two-factor authentication is already enabled. Disable it first to re-enroll.' });
60+
}
61+
const secret = rules.generateSecret();
62+
const otpauthUrl = rules.keyuri(user.email, secret);
63+
const qrDataUrl = await QRCode.toDataURL(otpauthUrl);
64+
await setTwoFactor(uid, { ...(user.twoFactor || {}), enabled: false, pendingSecretEnc: rules.encryptSecret(secret) });
65+
return res.status(200).json({
66+
status: true,
67+
statusText: 'Scan the QR code or enter the key in your authenticator app.',
68+
data: { otpauthUrl, qrDataUrl, secret }, // `secret` = manual-entry key, shown once
69+
});
70+
} catch (error) {
71+
logger.error(`2FA setup error: ${error?.message || error}`);
72+
return res.status(400).json({ status: false, message: error?.message || 'Failed to start 2FA setup' });
73+
}
74+
};
75+
76+
// POST /api/v2/auth/2fa/verify (authed) { code } — confirm a code, enable, return recovery codes.
77+
exports.twoFaVerify = async (req, res) => {
78+
try {
79+
const uid = req.uid;
80+
const code = req.body?.code;
81+
const user = await findUserAuthById(uid);
82+
if (!user?._id) return res.status(404).json({ status: false, message: 'User not found' });
83+
const pending = user.twoFactor && user.twoFactor.pendingSecretEnc;
84+
if (!pending) return res.status(400).json({ status: false, message: 'Start 2FA setup first.' });
85+
const secret = rules.decryptSecret(pending);
86+
if (!secret || !rules.verifyTotp(code, secret)) {
87+
return res.status(400).json({ status: false, message: 'That code is not valid. Check your authenticator app and try again.' });
88+
}
89+
const recoveryCodes = rules.generateRecoveryCodes();
90+
const hashed = await Promise.all(recoveryCodes.map((c) => rules.hashRecoveryCode(c)));
91+
await setTwoFactor(uid, { enabled: true, secretEnc: pending, recoveryCodes: hashed, enrolledAt: new Date() });
92+
return res.status(200).json({
93+
status: true,
94+
statusText: 'Two-factor authentication is now enabled. Save these recovery codes — they are shown only once.',
95+
data: { recoveryCodes },
96+
});
97+
} catch (error) {
98+
logger.error(`2FA verify error: ${error?.message || error}`);
99+
return res.status(400).json({ status: false, message: error?.message || 'Failed to enable 2FA' });
100+
}
101+
};
102+
103+
// POST /api/v2/auth/2fa/disable (authed) { code } — needs a current TOTP or a recovery code.
104+
exports.twoFaDisable = async (req, res) => {
105+
try {
106+
const uid = req.uid;
107+
const code = req.body?.code;
108+
const user = await findUserAuthById(uid);
109+
if (!user?._id) return res.status(404).json({ status: false, message: 'User not found' });
110+
if (!(user.twoFactor && user.twoFactor.enabled)) {
111+
return res.status(400).json({ status: false, message: 'Two-factor authentication is not enabled.' });
112+
}
113+
const secret = rules.decryptSecret(user.twoFactor.secretEnc);
114+
let ok = !!secret && rules.verifyTotp(code, secret);
115+
if (!ok) ok = (await findRecoveryMatch(code, user.twoFactor.recoveryCodes)) !== -1;
116+
if (!ok) {
117+
return res.status(400).json({ status: false, message: 'That code is not valid. Enter a current code or a recovery code to disable 2FA.' });
118+
}
119+
await setTwoFactor(uid, { enabled: false });
120+
return res.status(200).json({ status: true, statusText: 'Two-factor authentication has been disabled.' });
121+
} catch (error) {
122+
logger.error(`2FA disable error: ${error?.message || error}`);
123+
return res.status(400).json({ status: false, message: error?.message || 'Failed to disable 2FA' });
124+
}
125+
};
126+
127+
// POST /api/v2/auth/2fa/validate (PUBLIC) { tempToken, code } — login second-step.
128+
// On failure: set req.errorMessageObject + next() so the route's manageAttempt
129+
// handler rate-limits the attempt by IP (same as password login).
130+
exports.twoFaValidate = async (req, res, next) => {
131+
try {
132+
const { tempToken, code } = req.body || {};
133+
const payload = tempToken ? rules.verifyTempToken(tempToken) : null;
134+
if (!payload) {
135+
req.errorMessageObject = { message: 'Your verification session has expired. Please sign in again.' };
136+
return next();
137+
}
138+
const user = await findUserAuthById(payload.uid);
139+
if (!user?._id || !(user.twoFactor && user.twoFactor.enabled)) {
140+
req.errorMessageObject = { message: 'Two-factor authentication is not enabled for this account.' };
141+
return next();
142+
}
143+
const secret = rules.decryptSecret(user.twoFactor.secretEnc);
144+
let ok = !!secret && rules.verifyTotp(code, secret);
145+
if (!ok) {
146+
const idx = await findRecoveryMatch(code, user.twoFactor.recoveryCodes);
147+
if (idx !== -1) {
148+
ok = true;
149+
// Recovery codes are single-use — remove the one that matched.
150+
const remaining = user.twoFactor.recoveryCodes.slice();
151+
remaining.splice(idx, 1);
152+
await setTwoFactor(payload.uid, { ...user.twoFactor, recoveryCodes: remaining });
153+
}
154+
}
155+
if (!ok) {
156+
req.errorMessageObject = { message: 'That code is not valid. Try again or use a recovery code.' };
157+
return next();
158+
}
159+
// Identical session issuance to a normal password login.
160+
return loginSessionCtrl.finalizeSession(req, res, payload.uid, next);
161+
} catch (error) {
162+
logger.error(`2FA validate error: ${error?.message || error}`);
163+
req.errorMessageObject = { message: error?.message || 'Verification failed' };
164+
return next();
165+
}
166+
};

0 commit comments

Comments
 (0)