2fa work slight refactor

- use existing access mechanisms for validation
- adds swagger/schema and validation of incoming payload
This commit is contained in:
Jamie Curnow
2026-01-14 11:44:51 +10:00
parent b7dfaddbb1
commit 462c134751
14 changed files with 565 additions and 418 deletions

View File

@@ -1,9 +1,9 @@
import bcrypt from "bcrypt";
import crypto from "node:crypto";
import bcrypt from "bcrypt";
import { authenticator } from "otplib";
import authModel from "../models/auth.js";
import userModel from "../models/user.js";
import errs from "../lib/error.js";
import authModel from "../models/auth.js";
import internalUser from "./user.js";
const APP_NAME = "Nginx Proxy Manager";
const BACKUP_CODE_COUNT = 8;
@@ -26,38 +26,7 @@ const generateBackupCodes = async () => {
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;
}
},
const internal2fa = {
/**
* Check if user has 2FA enabled
@@ -65,94 +34,85 @@ export default {
* @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;
const auth = await internal2fa.getUserPasswordAuth(userId);
return auth?.meta?.totp_enabled === true;
},
/**
* Get 2FA status for user
* @param {number} userId
* @returns {Promise<{enabled: boolean, backupCodesRemaining: number}>}
* @param {Access} access
* @param {number} userId
* @returns {Promise<{enabled: boolean, backup_codes_remaining: number}>}
*/
getStatus: async (userId) => {
const auth = await authModel
.query()
.where("user_id", userId)
.where("type", "password")
.first();
getStatus: async (access, userId) => {
await access.can("users:password", userId);
await internalUser.get(access, { id: userId });
const auth = await internal2fa.getUserPasswordAuth(userId);
const enabled = auth?.meta?.totp_enabled === true;
let backup_codes_remaining = 0;
if (!auth || !auth.meta || !auth.meta.totp_enabled) {
return { enabled: false, backupCodesRemaining: 0 };
if (enabled) {
const backupCodes = auth.meta.backup_codes || [];
backup_codes_remaining = backupCodes.length;
}
const backupCodes = auth.meta.backup_codes || [];
return {
enabled: true,
backupCodesRemaining: backupCodes.length,
enabled,
backup_codes_remaining,
};
},
/**
* Start 2FA setup - store pending secret
* @param {number} userId
* @returns {Promise<{secret: string, otpauthUrl: string}>}
*
* @param {Access} access
* @param {number} userId
* @returns {Promise<{secret: string, otpauth_url: string}>}
*/
startSetup: async (userId) => {
const user = await userModel.query().where("id", userId).first();
if (!user) {
throw new errs.ItemNotFoundError("User not found");
}
startSetup: async (access, userId) => {
await access.can("users:password", userId);
const user = await internalUser.get(access, { id: userId });
const secret = authenticator.generateSecret();
const otpauthUrl = authenticator.keyuri(user.email, APP_NAME, secret);
const otpauth_url = authenticator.keyuri(user.email, APP_NAME, secret);
const auth = await internal2fa.getUserPasswordAuth(userId);
const auth = await authModel
.query()
.where("user_id", userId)
.where("type", "password")
.first();
if (!auth) {
throw new errs.ItemNotFoundError("Auth record not found");
// ensure user isn't already setup for 2fa
const enabled = auth?.meta?.totp_enabled === true;
if (enabled) {
throw new errs.ValidationError("2FA is already enabled");
}
const meta = auth.meta || {};
meta.totp_pending_secret = secret;
await authModel.query().where("id", auth.id).patch({ meta });
await authModel.query()
.where("id", auth.id)
.andWhere("user_id", userId)
.andWhere("type", "password")
.patch({ meta });
return { secret, otpauthUrl };
return { secret, otpauth_url };
},
/**
* Enable 2FA after verifying code
* @param {number} userId
* @param {string} code
* @returns {Promise<{backupCodes: string[]}>}
*
* @param {Access} access
* @param {number} userId
* @param {string} code
* @returns {Promise<{backup_codes: string[]}>}
*/
enable: async (userId, code) => {
const auth = await authModel
.query()
.where("user_id", userId)
.where("type", "password")
.first();
enable: async (access, userId, code) => {
await access.can("users:password", userId);
await internalUser.get(access, { id: userId });
const auth = await internal2fa.getUserPasswordAuth(userId);
const secret = auth?.meta?.totp_pending_secret || false;
if (!auth || !auth.meta || !auth.meta.totp_pending_secret) {
if (!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");
}
@@ -168,25 +128,31 @@ export default {
};
delete meta.totp_pending_secret;
await authModel.query().where("id", auth.id).patch({ meta });
await authModel
.query()
.where("id", auth.id)
.andWhere("user_id", userId)
.andWhere("type", "password")
.patch({ meta });
return { backupCodes: plain };
return { backup_codes: plain };
},
/**
* Disable 2FA
* @param {number} userId
* @param {string} code
*
* @param {Access} access
* @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();
disable: async (access, userId, code) => {
await access.can("users:password", userId);
await internalUser.get(access, { id: userId });
const auth = await internal2fa.getUserPasswordAuth(userId);
if (!auth || !auth.meta || !auth.meta.totp_enabled) {
const enabled = auth?.meta?.totp_enabled === true;
if (!enabled) {
throw new errs.ValidationError("2FA is not enabled");
}
@@ -196,7 +162,7 @@ export default {
});
if (!valid) {
throw new errs.ValidationError("Invalid verification code");
throw new errs.AuthError("Invalid verification code");
}
const meta = { ...auth.meta };
@@ -205,30 +171,33 @@ export default {
delete meta.totp_enabled_at;
delete meta.backup_codes;
await authModel.query().where("id", auth.id).patch({ meta });
await authModel
.query()
.where("id", auth.id)
.andWhere("user_id", userId)
.andWhere("type", "password")
.patch({ meta });
},
/**
* Verify 2FA code for login
* @param {number} userId
* @param {string} code
*
* @param {number} userId
* @param {string} token
* @returns {Promise<boolean>}
*/
verifyForLogin: async (userId, code) => {
const auth = await authModel
.query()
.where("user_id", userId)
.where("type", "password")
.first();
verifyForLogin: async (userId, token) => {
const auth = await internal2fa.getUserPasswordAuth(userId);
const secret = auth?.meta?.totp_secret || false;
if (!auth || !auth.meta || !auth.meta.totp_secret) {
if (!secret) {
return false;
}
// Try TOTP code first
const valid = authenticator.verify({
token: code,
secret: auth.meta.totp_secret,
token,
secret,
});
if (valid) {
@@ -236,7 +205,7 @@ export default {
}
// Try backup codes
const backupCodes = auth.meta.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) {
@@ -244,7 +213,12 @@ export default {
const updatedCodes = [...backupCodes];
updatedCodes.splice(i, 1);
const meta = { ...auth.meta, backup_codes: updatedCodes };
await authModel.query().where("id", auth.id).patch({ meta });
await authModel
.query()
.where("id", auth.id)
.andWhere("user_id", userId)
.andWhere("type", "password")
.patch({ meta });
return true;
}
}
@@ -254,24 +228,29 @@ export default {
/**
* Regenerate backup codes
* @param {number} userId
* @param {string} code
* @returns {Promise<{backupCodes: string[]}>}
*
* @param {Access} access
* @param {number} userId
* @param {string} token
* @returns {Promise<{backup_codes: string[]}>}
*/
regenerateBackupCodes: async (userId, code) => {
const auth = await authModel
.query()
.where("user_id", userId)
.where("type", "password")
.first();
regenerateBackupCodes: async (access, userId, token) => {
await access.can("users:password", userId);
await internalUser.get(access, { id: userId });
const auth = await internal2fa.getUserPasswordAuth(userId);
const enabled = auth?.meta?.totp_enabled === true;
const secret = auth?.meta?.totp_secret || false;
if (!auth || !auth.meta || !auth.meta.totp_enabled) {
if (!enabled) {
throw new errs.ValidationError("2FA is not enabled");
}
if (!secret) {
throw new errs.ValidationError("No 2FA secret found");
}
const valid = authenticator.verify({
token: code,
secret: auth.meta.totp_secret,
token,
secret,
});
if (!valid) {
@@ -281,8 +260,29 @@ export default {
const { plain, hashed } = await generateBackupCodes();
const meta = { ...auth.meta, backup_codes: hashed };
await authModel.query().where("id", auth.id).patch({ meta });
await authModel
.query()
.where("id", auth.id)
.andWhere("user_id", userId)
.andWhere("type", "password")
.patch({ meta });
return { backupCodes: plain };
return { backup_codes: plain };
},
getUserPasswordAuth: async (userId) => {
const auth = await authModel
.query()
.where("user_id", userId)
.andWhere("type", "password")
.first();
if (!auth) {
throw new errs.ItemNotFoundError("Auth not found");
}
return auth;
},
};
export default internal2fa;

View File

@@ -66,16 +66,7 @@ router
*/
.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 { challenge_token, code } = await apiValidator(getValidationSchema("/tokens/2fa", "post"), req.body);
const result = await internalToken.verify2FA(challenge_token, code);
res.status(200).send(result);
} catch (err) {

View File

@@ -339,6 +339,21 @@ router
.all(jwtdecode())
.all(userIdFromMe)
/**
* POST /api/users/123/2fa
*
* Start 2FA setup, returns QR code URL
*/
.post(async (req, res, next) => {
try {
const result = await internal2FA.startSetup(res.locals.access, req.params.user_id);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
})
/**
* GET /api/users/123/2fa
*
@@ -346,15 +361,7 @@ router
*/
.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);
const status = await internal2FA.getStatus(res.locals.access, req.params.user_id);
res.status(200).send(status);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -369,57 +376,12 @@ router
*/
.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);
const { code } = await apiValidator(
getValidationSchema("/users/{userID}/2fa", "delete"),
req.body,
);
await internal2FA.disable(res.locals.access, req.params.user_id, code);
res.status(200).send(true);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
@@ -440,26 +402,17 @@ router
.all(userIdFromMe)
/**
* PUT /api/users/123/2fa/enable
* POST /api/users/123/2fa/enable
*
* Verify code and enable 2FA
*/
.put(async (req, res, next) => {
.post(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);
const { code } = await apiValidator(
getValidationSchema("/users/{userID}/2fa/enable", "post"),
req.body,
);
const result = await internal2FA.enable(res.locals.access, req.params.user_id, code);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -487,20 +440,11 @@ router
*/
.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);
const { code } = await apiValidator(
getValidationSchema("/users/{userID}/2fa/backup-codes", "post"),
req.body,
);
const result = await internal2FA.regenerateBackupCodes(res.locals.access, req.params.user_id, code);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);

View File

@@ -0,0 +1,18 @@
{
"type": "object",
"description": "Token object",
"required": ["requires_2fa", "challenge_token"],
"additionalProperties": false,
"properties": {
"requires_2fa": {
"description": "Whether this token request requires two-factor authentication",
"example": true,
"type": "boolean"
},
"challenge_token": {
"description": "Challenge Token used in subsequent 2FA verification",
"example": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4",
"type": "string"
}
}
}

View File

@@ -0,0 +1,55 @@
{
"operationId": "loginWith2FA",
"summary": "Verify 2FA code and get full token",
"tags": ["tokens"],
"requestBody": {
"description": "2fa Challenge Payload",
"required": true,
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"challenge_token": {
"minLength": 1,
"type": "string",
"example": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4"
},
"code": {
"minLength": 6,
"maxLength": 6,
"type": "string",
"example": "012345"
}
},
"required": ["challenge_token", "code"],
"type": "object"
},
"example": {
"challenge_token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4",
"code": "012345"
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"examples": {
"default": {
"value": {
"expires": "2025-02-04T20:40:46.340Z",
"token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4"
}
}
},
"schema": {
"$ref": "../../../components/token-object.json"
}
}
},
"description": "200 response"
}
}
}

View File

@@ -50,7 +50,14 @@
}
},
"schema": {
"$ref": "../../components/token-object.json"
"oneOf": [
{
"$ref": "../../components/token-object.json"
},
{
"$ref": "../../components/token-challenge.json"
}
]
}
}
},

View File

@@ -0,0 +1,79 @@
{
"operationId": "regenUser2faCodes",
"summary": "Regenerate 2FA backup codes",
"tags": ["users"],
"requestBody": {
"description": "Verififcation Payload",
"required": true,
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"code": {
"minLength": 6,
"maxLength": 6,
"type": "string",
"example": "123456"
}
},
"required": ["code"],
"type": "object"
},
"example": {
"code": "123456"
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"examples": {
"default": {
"value": {
"backup_codes": [
"6CD7CB06",
"495302F3",
"D8037852",
"A6FFC956",
"BC1A1851",
"A05E644F",
"A406D2E8",
"0AE3C522"
]
}
}
},
"schema": {
"type": "object",
"required": ["backup_codes"],
"additionalProperties": false,
"properties": {
"backup_codes": {
"description": "Backup codes",
"example": [
"6CD7CB06",
"495302F3",
"D8037852",
"A6FFC956",
"BC1A1851",
"A05E644F",
"A406D2E8",
"0AE3C522"
],
"type": "array",
"items": {
"type": "string",
"example": "6CD7CB06"
}
}
}
}
}
},
"description": "200 response"
}
}
}

View File

@@ -0,0 +1,46 @@
{
"operationId": "disableUser2fa",
"summary": "Disable 2fa for user",
"tags": ["users"],
"requestBody": {
"description": "2fa Code Payload",
"required": true,
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"code": {
"minLength": 6,
"maxLength": 6,
"type": "string",
"example": "012345"
}
},
"required": ["code"],
"type": "object"
},
"example": {
"code": "012345"
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"examples": {
"default": {
"value": true
}
},
"schema": {
"type": "boolean"
}
}
},
"description": "200 response"
}
}
}

View File

@@ -0,0 +1,79 @@
{
"operationId": "enableUser2fa",
"summary": "Verify code and enable 2FA",
"tags": ["users"],
"requestBody": {
"description": "Verififcation Payload",
"required": true,
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"code": {
"minLength": 6,
"maxLength": 6,
"type": "string",
"example": "123456"
}
},
"required": ["code"],
"type": "object"
},
"example": {
"code": "123456"
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"examples": {
"default": {
"value": {
"backup_codes": [
"6CD7CB06",
"495302F3",
"D8037852",
"A6FFC956",
"BC1A1851",
"A05E644F",
"A406D2E8",
"0AE3C522"
]
}
}
},
"schema": {
"type": "object",
"required": ["backup_codes"],
"additionalProperties": false,
"properties": {
"backup_codes": {
"description": "Backup codes",
"example": [
"6CD7CB06",
"495302F3",
"D8037852",
"A6FFC956",
"BC1A1851",
"A05E644F",
"A406D2E8",
"0AE3C522"
],
"type": "array",
"items": {
"type": "string",
"example": "6CD7CB06"
}
}
}
}
}
},
"description": "200 response"
}
}
}

View File

@@ -0,0 +1,46 @@
{
"operationId": "getUser2faStatus",
"summary": "Get user 2fa Status",
"tags": ["users"],
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "200 response",
"content": {
"application/json": {
"examples": {
"default": {
"value": [
{
"enabled": false,
"backup_codes_remaining": 0
}
]
}
},
"schema": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "backup_codes_remaining"],
"properties": {
"enabled": {
"type": "boolean",
"description": "Is 2FA enabled for this user",
"example": true
},
"backup_codes_remaining": {
"type": "integer",
"description": "Number of remaining backup codes for this user",
"example": 5
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,39 @@
{
"operationId": "setupUser2fa",
"summary": "Start 2FA setup, returns QR code URL",
"tags": ["users"],
"responses": {
"200": {
"content": {
"application/json": {
"examples": {
"default": {
"value": {
"secret": "JZYCEBIEEJYUGPQM",
"otpauth_url": "otpauth://totp/Nginx%20Proxy%20Manager:jc%40jc21.com?secret=JZYCEBIEEJYUGPQM&period=30&digits=6&algorithm=SHA1&issuer=Nginx%20Proxy%20Manager"
}
}
},
"schema": {
"type": "object",
"required": ["secret", "otpauth_url"],
"additionalProperties": false,
"properties": {
"secret": {
"description": "TOTP Secret",
"example": "JZYCEBIEEJYUGPQM",
"type": "string"
},
"otpauth_url": {
"description": "OTP Auth URL for QR Code generation",
"example": "otpauth://totp/Nginx%20Proxy%20Manager:jc%40jc21.com?secret=JZYCEBIEEJYUGPQM&period=30&digits=6&algorithm=SHA1&issuer=Nginx%20Proxy%20Manager",
"type": "string"
}
}
}
}
},
"description": "200 response"
}
}
}

View File

@@ -293,6 +293,11 @@
"$ref": "./paths/tokens/post.json"
}
},
"/tokens/2fa": {
"post": {
"$ref": "./paths/tokens/2fa/post.json"
}
},
"/version/check": {
"get": {
"$ref": "./paths/version/check/get.json"
@@ -317,6 +322,27 @@
"$ref": "./paths/users/userID/delete.json"
}
},
"/users/{userID}/2fa": {
"post": {
"$ref": "./paths/users/userID/2fa/post.json"
},
"get": {
"$ref": "./paths/users/userID/2fa/get.json"
},
"delete": {
"$ref": "./paths/users/userID/2fa/delete.json"
}
},
"/users/{userID}/2fa/enable": {
"post": {
"$ref": "./paths/users/userID/2fa/enable/post.json"
}
},
"/users/{userID}/2fa/backup-codes": {
"post": {
"$ref": "./paths/users/userID/2fa/backup-codes/post.json"
}
},
"/users/{userID}/auth": {
"put": {
"$ref": "./paths/users/userID/auth/put.json"