mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-01-21 19:25:43 +00:00
Add TOTP-based two-factor authentication
- Add 2FA setup, enable, disable, and backup code management - Integrate 2FA challenge flow into login process - Add frontend modal for 2FA configuration - Support backup codes for account recovery
This commit is contained in:
288
backend/internal/2fa.js
Normal file
288
backend/internal/2fa.js
Normal 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 };
|
||||
},
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user