Skip to content

Commit b30f8e4

Browse files
authored
Merge pull request #5109 from piotrfx/develop
Add TOTP-based two-factor authentication
2 parents 6fa3084 + d2d204a commit b30f8e4

File tree

16 files changed

+1496
-72
lines changed

16 files changed

+1496
-72
lines changed

backend/internal/2fa.js

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import bcrypt from "bcrypt";
2+
import crypto from "node:crypto";
3+
import { authenticator } from "otplib";
4+
import authModel from "../models/auth.js";
5+
import userModel from "../models/user.js";
6+
import errs from "../lib/error.js";
7+
8+
const APP_NAME = "Nginx Proxy Manager";
9+
const BACKUP_CODE_COUNT = 8;
10+
11+
/**
12+
* Generate backup codes
13+
* @returns {Promise<{plain: string[], hashed: string[]}>}
14+
*/
15+
const generateBackupCodes = async () => {
16+
const plain = [];
17+
const hashed = [];
18+
19+
for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
20+
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
21+
plain.push(code);
22+
const hash = await bcrypt.hash(code, 10);
23+
hashed.push(hash);
24+
}
25+
26+
return { plain, hashed };
27+
};
28+
29+
export default {
30+
/**
31+
* Generate a new TOTP secret
32+
* @returns {string}
33+
*/
34+
generateSecret: () => {
35+
return authenticator.generateSecret();
36+
},
37+
38+
/**
39+
* Generate otpauth URL for QR code
40+
* @param {string} email
41+
* @param {string} secret
42+
* @returns {string}
43+
*/
44+
generateOTPAuthURL: (email, secret) => {
45+
return authenticator.keyuri(email, APP_NAME, secret);
46+
},
47+
48+
/**
49+
* Verify a TOTP code
50+
* @param {string} secret
51+
* @param {string} code
52+
* @returns {boolean}
53+
*/
54+
verifyCode: (secret, code) => {
55+
try {
56+
return authenticator.verify({ token: code, secret });
57+
} catch {
58+
return false;
59+
}
60+
},
61+
62+
/**
63+
* Check if user has 2FA enabled
64+
* @param {number} userId
65+
* @returns {Promise<boolean>}
66+
*/
67+
isEnabled: async (userId) => {
68+
const auth = await authModel
69+
.query()
70+
.where("user_id", userId)
71+
.where("type", "password")
72+
.first();
73+
74+
if (!auth || !auth.meta) {
75+
return false;
76+
}
77+
78+
return auth.meta.totp_enabled === true;
79+
},
80+
81+
/**
82+
* Get 2FA status for user
83+
* @param {number} userId
84+
* @returns {Promise<{enabled: boolean, backupCodesRemaining: number}>}
85+
*/
86+
getStatus: async (userId) => {
87+
const auth = await authModel
88+
.query()
89+
.where("user_id", userId)
90+
.where("type", "password")
91+
.first();
92+
93+
if (!auth || !auth.meta || !auth.meta.totp_enabled) {
94+
return { enabled: false, backupCodesRemaining: 0 };
95+
}
96+
97+
const backupCodes = auth.meta.backup_codes || [];
98+
return {
99+
enabled: true,
100+
backupCodesRemaining: backupCodes.length,
101+
};
102+
},
103+
104+
/**
105+
* Start 2FA setup - store pending secret
106+
* @param {number} userId
107+
* @returns {Promise<{secret: string, otpauthUrl: string}>}
108+
*/
109+
startSetup: async (userId) => {
110+
const user = await userModel.query().where("id", userId).first();
111+
if (!user) {
112+
throw new errs.ItemNotFoundError("User not found");
113+
}
114+
115+
const secret = authenticator.generateSecret();
116+
const otpauthUrl = authenticator.keyuri(user.email, APP_NAME, secret);
117+
118+
const auth = await authModel
119+
.query()
120+
.where("user_id", userId)
121+
.where("type", "password")
122+
.first();
123+
124+
if (!auth) {
125+
throw new errs.ItemNotFoundError("Auth record not found");
126+
}
127+
128+
const meta = auth.meta || {};
129+
meta.totp_pending_secret = secret;
130+
131+
await authModel.query().where("id", auth.id).patch({ meta });
132+
133+
return { secret, otpauthUrl };
134+
},
135+
136+
/**
137+
* Enable 2FA after verifying code
138+
* @param {number} userId
139+
* @param {string} code
140+
* @returns {Promise<{backupCodes: string[]}>}
141+
*/
142+
enable: async (userId, code) => {
143+
const auth = await authModel
144+
.query()
145+
.where("user_id", userId)
146+
.where("type", "password")
147+
.first();
148+
149+
if (!auth || !auth.meta || !auth.meta.totp_pending_secret) {
150+
throw new errs.ValidationError("No pending 2FA setup found");
151+
}
152+
153+
const secret = auth.meta.totp_pending_secret;
154+
const valid = authenticator.verify({ token: code, secret });
155+
156+
if (!valid) {
157+
throw new errs.ValidationError("Invalid verification code");
158+
}
159+
160+
const { plain, hashed } = await generateBackupCodes();
161+
162+
const meta = {
163+
...auth.meta,
164+
totp_secret: secret,
165+
totp_enabled: true,
166+
totp_enabled_at: new Date().toISOString(),
167+
backup_codes: hashed,
168+
};
169+
delete meta.totp_pending_secret;
170+
171+
await authModel.query().where("id", auth.id).patch({ meta });
172+
173+
return { backupCodes: plain };
174+
},
175+
176+
/**
177+
* Disable 2FA
178+
* @param {number} userId
179+
* @param {string} code
180+
* @returns {Promise<void>}
181+
*/
182+
disable: async (userId, code) => {
183+
const auth = await authModel
184+
.query()
185+
.where("user_id", userId)
186+
.where("type", "password")
187+
.first();
188+
189+
if (!auth || !auth.meta || !auth.meta.totp_enabled) {
190+
throw new errs.ValidationError("2FA is not enabled");
191+
}
192+
193+
const valid = authenticator.verify({
194+
token: code,
195+
secret: auth.meta.totp_secret,
196+
});
197+
198+
if (!valid) {
199+
throw new errs.ValidationError("Invalid verification code");
200+
}
201+
202+
const meta = { ...auth.meta };
203+
delete meta.totp_secret;
204+
delete meta.totp_enabled;
205+
delete meta.totp_enabled_at;
206+
delete meta.backup_codes;
207+
208+
await authModel.query().where("id", auth.id).patch({ meta });
209+
},
210+
211+
/**
212+
* Verify 2FA code for login
213+
* @param {number} userId
214+
* @param {string} code
215+
* @returns {Promise<boolean>}
216+
*/
217+
verifyForLogin: async (userId, code) => {
218+
const auth = await authModel
219+
.query()
220+
.where("user_id", userId)
221+
.where("type", "password")
222+
.first();
223+
224+
if (!auth || !auth.meta || !auth.meta.totp_secret) {
225+
return false;
226+
}
227+
228+
// Try TOTP code first
229+
const valid = authenticator.verify({
230+
token: code,
231+
secret: auth.meta.totp_secret,
232+
});
233+
234+
if (valid) {
235+
return true;
236+
}
237+
238+
// Try backup codes
239+
const backupCodes = auth.meta.backup_codes || [];
240+
for (let i = 0; i < backupCodes.length; i++) {
241+
const match = await bcrypt.compare(code.toUpperCase(), backupCodes[i]);
242+
if (match) {
243+
// Remove used backup code
244+
const updatedCodes = [...backupCodes];
245+
updatedCodes.splice(i, 1);
246+
const meta = { ...auth.meta, backup_codes: updatedCodes };
247+
await authModel.query().where("id", auth.id).patch({ meta });
248+
return true;
249+
}
250+
}
251+
252+
return false;
253+
},
254+
255+
/**
256+
* Regenerate backup codes
257+
* @param {number} userId
258+
* @param {string} code
259+
* @returns {Promise<{backupCodes: string[]}>}
260+
*/
261+
regenerateBackupCodes: async (userId, code) => {
262+
const auth = await authModel
263+
.query()
264+
.where("user_id", userId)
265+
.where("type", "password")
266+
.first();
267+
268+
if (!auth || !auth.meta || !auth.meta.totp_enabled) {
269+
throw new errs.ValidationError("2FA is not enabled");
270+
}
271+
272+
const valid = authenticator.verify({
273+
token: code,
274+
secret: auth.meta.totp_secret,
275+
});
276+
277+
if (!valid) {
278+
throw new errs.ValidationError("Invalid verification code");
279+
}
280+
281+
const { plain, hashed } = await generateBackupCodes();
282+
283+
const meta = { ...auth.meta, backup_codes: hashed };
284+
await authModel.query().where("id", auth.id).patch({ meta });
285+
286+
return { backupCodes: plain };
287+
},
288+
};

backend/internal/token.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import { parseDatePeriod } from "../lib/helpers.js";
44
import authModel from "../models/auth.js";
55
import TokenModel from "../models/token.js";
66
import userModel from "../models/user.js";
7+
import twoFactor from "./2fa.js";
78

89
const ERROR_MESSAGE_INVALID_AUTH = "Invalid email or password";
910
const ERROR_MESSAGE_INVALID_AUTH_I18N = "error.invalid-auth";
11+
const ERROR_MESSAGE_INVALID_2FA = "Invalid verification code";
12+
const ERROR_MESSAGE_INVALID_2FA_I18N = "error.invalid-2fa";
1013

1114
export default {
1215
/**
@@ -59,6 +62,25 @@ export default {
5962
throw new errs.AuthError(`Invalid scope: ${data.scope}`);
6063
}
6164

65+
// Check if 2FA is enabled
66+
const has2FA = await twoFactor.isEnabled(user.id);
67+
if (has2FA) {
68+
// Return challenge token instead of full token
69+
const challengeToken = await Token.create({
70+
iss: issuer || "api",
71+
attrs: {
72+
id: user.id,
73+
},
74+
scope: ["2fa-challenge"],
75+
expiresIn: "5m",
76+
});
77+
78+
return {
79+
requires_2fa: true,
80+
challenge_token: challengeToken.token,
81+
};
82+
}
83+
6284
// Create a moment of the expiry expression
6385
const expiry = parseDatePeriod(data.expiry);
6486
if (expiry === null) {
@@ -129,6 +151,65 @@ export default {
129151
throw new error.AssertionFailedError("Existing token contained invalid user data");
130152
},
131153

154+
/**
155+
* Verify 2FA code and return full token
156+
* @param {string} challengeToken
157+
* @param {string} code
158+
* @param {string} [expiry]
159+
* @returns {Promise}
160+
*/
161+
verify2FA: async (challengeToken, code, expiry) => {
162+
const Token = TokenModel();
163+
const tokenExpiry = expiry || "1d";
164+
165+
// Verify challenge token
166+
let tokenData;
167+
try {
168+
tokenData = await Token.load(challengeToken);
169+
} catch {
170+
throw new errs.AuthError("Invalid or expired challenge token");
171+
}
172+
173+
// Check scope
174+
if (!tokenData.scope || tokenData.scope[0] !== "2fa-challenge") {
175+
throw new errs.AuthError("Invalid challenge token");
176+
}
177+
178+
const userId = tokenData.attrs?.id;
179+
if (!userId) {
180+
throw new errs.AuthError("Invalid challenge token");
181+
}
182+
183+
// Verify 2FA code
184+
const valid = await twoFactor.verifyForLogin(userId, code);
185+
if (!valid) {
186+
throw new errs.AuthError(
187+
ERROR_MESSAGE_INVALID_2FA,
188+
ERROR_MESSAGE_INVALID_2FA_I18N,
189+
);
190+
}
191+
192+
// Create full token
193+
const expiryDate = parseDatePeriod(tokenExpiry);
194+
if (expiryDate === null) {
195+
throw new errs.AuthError(`Invalid expiry time: ${tokenExpiry}`);
196+
}
197+
198+
const signed = await Token.create({
199+
iss: "api",
200+
attrs: {
201+
id: userId,
202+
},
203+
scope: ["user"],
204+
expiresIn: tokenExpiry,
205+
});
206+
207+
return {
208+
token: signed.token,
209+
expires: expiryDate.toISOString(),
210+
};
211+
},
212+
132213
/**
133214
* @param {Object} user
134215
* @returns {Promise}

0 commit comments

Comments
 (0)