Merge pull request #5109 from piotrfx/develop

Add TOTP-based two-factor authentication
This commit is contained in:
jc21
2026-01-13 14:30:48 +10:00
committed by GitHub
16 changed files with 1496 additions and 72 deletions

288
backend/internal/2fa.js Normal file
View File

@@ -0,0 +1,288 @@
import bcrypt from "bcrypt";
import crypto from "node:crypto";
import { authenticator } from "otplib";
import authModel from "../models/auth.js";
import userModel from "../models/user.js";
import errs from "../lib/error.js";
const APP_NAME = "Nginx Proxy Manager";
const BACKUP_CODE_COUNT = 8;
/**
* Generate backup codes
* @returns {Promise<{plain: string[], hashed: string[]}>}
*/
const generateBackupCodes = async () => {
const plain = [];
const hashed = [];
for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
plain.push(code);
const hash = await bcrypt.hash(code, 10);
hashed.push(hash);
}
return { plain, hashed };
};
export default {
/**
* Generate a new TOTP secret
* @returns {string}
*/
generateSecret: () => {
return authenticator.generateSecret();
},
/**
* Generate otpauth URL for QR code
* @param {string} email
* @param {string} secret
* @returns {string}
*/
generateOTPAuthURL: (email, secret) => {
return authenticator.keyuri(email, APP_NAME, secret);
},
/**
* Verify a TOTP code
* @param {string} secret
* @param {string} code
* @returns {boolean}
*/
verifyCode: (secret, code) => {
try {
return authenticator.verify({ token: code, secret });
} catch {
return false;
}
},
/**
* Check if user has 2FA enabled
* @param {number} userId
* @returns {Promise<boolean>}
*/
isEnabled: async (userId) => {
const auth = await authModel
.query()
.where("user_id", userId)
.where("type", "password")
.first();
if (!auth || !auth.meta) {
return false;
}
return auth.meta.totp_enabled === true;
},
/**
* Get 2FA status for user
* @param {number} userId
* @returns {Promise<{enabled: boolean, backupCodesRemaining: number}>}
*/
getStatus: async (userId) => {
const auth = await authModel
.query()
.where("user_id", userId)
.where("type", "password")
.first();
if (!auth || !auth.meta || !auth.meta.totp_enabled) {
return { enabled: false, backupCodesRemaining: 0 };
}
const backupCodes = auth.meta.backup_codes || [];
return {
enabled: true,
backupCodesRemaining: backupCodes.length,
};
},
/**
* Start 2FA setup - store pending secret
* @param {number} userId
* @returns {Promise<{secret: string, otpauthUrl: string}>}
*/
startSetup: async (userId) => {
const user = await userModel.query().where("id", userId).first();
if (!user) {
throw new errs.ItemNotFoundError("User not found");
}
const secret = authenticator.generateSecret();
const otpauthUrl = authenticator.keyuri(user.email, APP_NAME, secret);
const auth = await authModel
.query()
.where("user_id", userId)
.where("type", "password")
.first();
if (!auth) {
throw new errs.ItemNotFoundError("Auth record not found");
}
const meta = auth.meta || {};
meta.totp_pending_secret = secret;
await authModel.query().where("id", auth.id).patch({ meta });
return { secret, otpauthUrl };
},
/**
* Enable 2FA after verifying code
* @param {number} userId
* @param {string} code
* @returns {Promise<{backupCodes: string[]}>}
*/
enable: async (userId, code) => {
const auth = await authModel
.query()
.where("user_id", userId)
.where("type", "password")
.first();
if (!auth || !auth.meta || !auth.meta.totp_pending_secret) {
throw new errs.ValidationError("No pending 2FA setup found");
}
const secret = auth.meta.totp_pending_secret;
const valid = authenticator.verify({ token: code, secret });
if (!valid) {
throw new errs.ValidationError("Invalid verification code");
}
const { plain, hashed } = await generateBackupCodes();
const meta = {
...auth.meta,
totp_secret: secret,
totp_enabled: true,
totp_enabled_at: new Date().toISOString(),
backup_codes: hashed,
};
delete meta.totp_pending_secret;
await authModel.query().where("id", auth.id).patch({ meta });
return { backupCodes: plain };
},
/**
* Disable 2FA
* @param {number} userId
* @param {string} code
* @returns {Promise<void>}
*/
disable: async (userId, code) => {
const auth = await authModel
.query()
.where("user_id", userId)
.where("type", "password")
.first();
if (!auth || !auth.meta || !auth.meta.totp_enabled) {
throw new errs.ValidationError("2FA is not enabled");
}
const valid = authenticator.verify({
token: code,
secret: auth.meta.totp_secret,
});
if (!valid) {
throw new errs.ValidationError("Invalid verification code");
}
const meta = { ...auth.meta };
delete meta.totp_secret;
delete meta.totp_enabled;
delete meta.totp_enabled_at;
delete meta.backup_codes;
await authModel.query().where("id", auth.id).patch({ meta });
},
/**
* Verify 2FA code for login
* @param {number} userId
* @param {string} code
* @returns {Promise<boolean>}
*/
verifyForLogin: async (userId, code) => {
const auth = await authModel
.query()
.where("user_id", userId)
.where("type", "password")
.first();
if (!auth || !auth.meta || !auth.meta.totp_secret) {
return false;
}
// Try TOTP code first
const valid = authenticator.verify({
token: code,
secret: auth.meta.totp_secret,
});
if (valid) {
return true;
}
// Try backup codes
const backupCodes = auth.meta.backup_codes || [];
for (let i = 0; i < backupCodes.length; i++) {
const match = await bcrypt.compare(code.toUpperCase(), backupCodes[i]);
if (match) {
// Remove used backup code
const updatedCodes = [...backupCodes];
updatedCodes.splice(i, 1);
const meta = { ...auth.meta, backup_codes: updatedCodes };
await authModel.query().where("id", auth.id).patch({ meta });
return true;
}
}
return false;
},
/**
* Regenerate backup codes
* @param {number} userId
* @param {string} code
* @returns {Promise<{backupCodes: string[]}>}
*/
regenerateBackupCodes: async (userId, code) => {
const auth = await authModel
.query()
.where("user_id", userId)
.where("type", "password")
.first();
if (!auth || !auth.meta || !auth.meta.totp_enabled) {
throw new errs.ValidationError("2FA is not enabled");
}
const valid = authenticator.verify({
token: code,
secret: auth.meta.totp_secret,
});
if (!valid) {
throw new errs.ValidationError("Invalid verification code");
}
const { plain, hashed } = await generateBackupCodes();
const meta = { ...auth.meta, backup_codes: hashed };
await authModel.query().where("id", auth.id).patch({ meta });
return { backupCodes: plain };
},
};

View File

@@ -4,9 +4,12 @@ import { parseDatePeriod } from "../lib/helpers.js";
import authModel from "../models/auth.js";
import TokenModel from "../models/token.js";
import userModel from "../models/user.js";
import twoFactor from "./2fa.js";
const ERROR_MESSAGE_INVALID_AUTH = "Invalid email or password";
const ERROR_MESSAGE_INVALID_AUTH_I18N = "error.invalid-auth";
const ERROR_MESSAGE_INVALID_2FA = "Invalid verification code";
const ERROR_MESSAGE_INVALID_2FA_I18N = "error.invalid-2fa";
export default {
/**
@@ -59,6 +62,25 @@ export default {
throw new errs.AuthError(`Invalid scope: ${data.scope}`);
}
// Check if 2FA is enabled
const has2FA = await twoFactor.isEnabled(user.id);
if (has2FA) {
// Return challenge token instead of full token
const challengeToken = await Token.create({
iss: issuer || "api",
attrs: {
id: user.id,
},
scope: ["2fa-challenge"],
expiresIn: "5m",
});
return {
requires_2fa: true,
challenge_token: challengeToken.token,
};
}
// Create a moment of the expiry expression
const expiry = parseDatePeriod(data.expiry);
if (expiry === null) {
@@ -129,6 +151,65 @@ export default {
throw new error.AssertionFailedError("Existing token contained invalid user data");
},
/**
* Verify 2FA code and return full token
* @param {string} challengeToken
* @param {string} code
* @param {string} [expiry]
* @returns {Promise}
*/
verify2FA: async (challengeToken, code, expiry) => {
const Token = TokenModel();
const tokenExpiry = expiry || "1d";
// Verify challenge token
let tokenData;
try {
tokenData = await Token.load(challengeToken);
} catch {
throw new errs.AuthError("Invalid or expired challenge token");
}
// Check scope
if (!tokenData.scope || tokenData.scope[0] !== "2fa-challenge") {
throw new errs.AuthError("Invalid challenge token");
}
const userId = tokenData.attrs?.id;
if (!userId) {
throw new errs.AuthError("Invalid challenge token");
}
// Verify 2FA code
const valid = await twoFactor.verifyForLogin(userId, code);
if (!valid) {
throw new errs.AuthError(
ERROR_MESSAGE_INVALID_2FA,
ERROR_MESSAGE_INVALID_2FA_I18N,
);
}
// Create full token
const expiryDate = parseDatePeriod(tokenExpiry);
if (expiryDate === null) {
throw new errs.AuthError(`Invalid expiry time: ${tokenExpiry}`);
}
const signed = await Token.create({
iss: "api",
attrs: {
id: userId,
},
scope: ["user"],
expiresIn: tokenExpiry,
});
return {
token: signed.token,
expires: expiryDate.toISOString(),
};
},
/**
* @param {Object} user
* @returns {Promise}

View File

@@ -30,6 +30,7 @@
"mysql2": "^3.15.3",
"node-rsa": "^1.1.1",
"objection": "3.0.1",
"otplib": "^12.0.1",
"path": "^0.12.7",
"pg": "^8.16.3",
"proxy-agent": "^6.5.0",

View File

@@ -53,4 +53,35 @@ router
}
});
router
.route("/2fa")
.options((_, res) => {
res.sendStatus(204);
})
/**
* POST /tokens/2fa
*
* Verify 2FA code and get full token
*/
.post(async (req, res, next) => {
try {
const { challenge_token, code } = req.body;
if (!challenge_token || !code) {
return res.status(400).json({
error: {
message: "Missing challenge_token or code",
},
});
}
const result = await internalToken.verify2FA(challenge_token, code);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
export default router;

View File

@@ -1,4 +1,5 @@
import express from "express";
import internal2FA from "../internal/2fa.js";
import internalUser from "../internal/user.js";
import Access from "../lib/access.js";
import { isCI } from "../lib/config.js";
@@ -325,4 +326,186 @@ router
}
});
/**
* User 2FA status
*
* /api/users/123/2fa
*/
router
.route("/:user_id/2fa")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
.all(userIdFromMe)
/**
* GET /api/users/123/2fa
*
* Get 2FA status for a user
*/
.get(async (req, res, next) => {
try {
const userId = Number.parseInt(req.params.user_id, 10);
const access = res.locals.access;
// Users can only view their own 2FA status
if (access.token.getUserId() !== userId && !access.token.hasScope("admin")) {
throw new errs.PermissionError("Cannot view 2FA status for other users");
}
const status = await internal2FA.getStatus(userId);
res.status(200).send(status);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
})
/**
* DELETE /api/users/123/2fa
*
* Disable 2FA for a user
*/
.delete(async (req, res, next) => {
try {
const userId = Number.parseInt(req.params.user_id, 10);
const access = res.locals.access;
// Users can only disable their own 2FA
if (access.token.getUserId() !== userId && !access.token.hasScope("admin")) {
throw new errs.PermissionError("Cannot disable 2FA for other users");
}
const { code } = req.body;
if (!code) {
throw new errs.ValidationError("Verification code is required");
}
await internal2FA.disable(userId, code);
res.status(200).send({ success: true });
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
/**
* User 2FA setup
*
* /api/users/123/2fa/setup
*/
router
.route("/:user_id/2fa/setup")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
.all(userIdFromMe)
/**
* POST /api/users/123/2fa/setup
*
* Start 2FA setup, returns QR code URL
*/
.post(async (req, res, next) => {
try {
const userId = Number.parseInt(req.params.user_id, 10);
const access = res.locals.access;
// Users can only setup their own 2FA
if (access.token.getUserId() !== userId) {
throw new errs.PermissionError("Cannot setup 2FA for other users");
}
const result = await internal2FA.startSetup(userId);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
/**
* User 2FA enable
*
* /api/users/123/2fa/enable
*/
router
.route("/:user_id/2fa/enable")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
.all(userIdFromMe)
/**
* PUT /api/users/123/2fa/enable
*
* Verify code and enable 2FA
*/
.put(async (req, res, next) => {
try {
const userId = Number.parseInt(req.params.user_id, 10);
const access = res.locals.access;
// Users can only enable their own 2FA
if (access.token.getUserId() !== userId) {
throw new errs.PermissionError("Cannot enable 2FA for other users");
}
const { code } = req.body;
if (!code) {
throw new errs.ValidationError("Verification code is required");
}
const result = await internal2FA.enable(userId, code);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
/**
* User 2FA backup codes
*
* /api/users/123/2fa/backup-codes
*/
router
.route("/:user_id/2fa/backup-codes")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
.all(userIdFromMe)
/**
* POST /api/users/123/2fa/backup-codes
*
* Regenerate backup codes
*/
.post(async (req, res, next) => {
try {
const userId = Number.parseInt(req.params.user_id, 10);
const access = res.locals.access;
// Users can only regenerate their own backup codes
if (access.token.getUserId() !== userId) {
throw new errs.PermissionError("Cannot regenerate backup codes for other users");
}
const { code } = req.body;
if (!code) {
throw new errs.ValidationError("Verification code is required");
}
const result = await internal2FA.regenerateBackupCodes(userId, code);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
export default router;