mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-01-21 19:25:43 +00:00
Compare commits
17 Commits
dependabot
...
v2.13.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3efaae320 | ||
|
|
7b3c1fd061 | ||
|
|
ee42202348 | ||
|
|
c1ad7788f1 | ||
|
|
d33bb02c74 | ||
|
|
462c134751 | ||
|
|
b7dfaddbb1 | ||
|
|
073ee95e56 | ||
|
|
168078eb40 | ||
|
|
2c9f8f4d64 | ||
|
|
8403a0c761 | ||
|
|
56875bba52 | ||
|
|
b55f51bd63 | ||
|
|
86b7394620 | ||
|
|
91a1f39c02 | ||
|
|
fec9bffe29 | ||
|
|
847c58b170 |
@@ -1,7 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="https://nginxproxymanager.com/github.png">
|
||||
<br><br>
|
||||
<img src="https://img.shields.io/badge/version-2.13.5-green.svg?style=for-the-badge">
|
||||
<img src="https://img.shields.io/badge/version-2.13.6-green.svg?style=for-the-badge">
|
||||
<a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager">
|
||||
<img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge">
|
||||
</a>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
@@ -363,63 +370,18 @@ router
|
||||
})
|
||||
|
||||
/**
|
||||
* DELETE /api/users/123/2fa
|
||||
* DELETE /api/users/123/2fa?code=XXXXXX
|
||||
*
|
||||
* 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;
|
||||
const code = typeof req.query.code === "string" ? req.query.code : null;
|
||||
if (!code) {
|
||||
throw new errs.ValidationError("Verification code is required");
|
||||
throw new errs.ValidationError("Missing required parameter: code");
|
||||
}
|
||||
|
||||
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);
|
||||
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}`);
|
||||
|
||||
18
backend/schema/components/token-challenge.json
Normal file
18
backend/schema/components/token-challenge.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
55
backend/schema/paths/tokens/2fa/post.json
Normal file
55
backend/schema/paths/tokens/2fa/post.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,14 @@
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"$ref": "../../components/token-object.json"
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "../../components/token-object.json"
|
||||
},
|
||||
{
|
||||
"$ref": "../../components/token-challenge.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
92
backend/schema/paths/users/userID/2fa/backup-codes/post.json
Normal file
92
backend/schema/paths/users/userID/2fa/backup-codes/post.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"operationId": "regenUser2faCodes",
|
||||
"summary": "Regenerate 2FA backup codes",
|
||||
"tags": ["users"],
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "userID",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"required": true,
|
||||
"description": "User ID",
|
||||
"example": 2
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
48
backend/schema/paths/users/userID/2fa/delete.json
Normal file
48
backend/schema/paths/users/userID/2fa/delete.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"operationId": "disableUser2fa",
|
||||
"summary": "Disable 2fa for user",
|
||||
"tags": ["users"],
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "userID",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"required": true,
|
||||
"description": "User ID",
|
||||
"example": 2
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "code",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 6,
|
||||
"maxLength": 6,
|
||||
"example": "012345"
|
||||
},
|
||||
"required": true,
|
||||
"description": "2fa Code",
|
||||
"example": "012345"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"default": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "200 response"
|
||||
}
|
||||
}
|
||||
}
|
||||
92
backend/schema/paths/users/userID/2fa/enable/post.json
Normal file
92
backend/schema/paths/users/userID/2fa/enable/post.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"operationId": "enableUser2fa",
|
||||
"summary": "Verify code and enable 2FA",
|
||||
"tags": ["users"],
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "userID",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"required": true,
|
||||
"description": "User ID",
|
||||
"example": 2
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
57
backend/schema/paths/users/userID/2fa/get.json
Normal file
57
backend/schema/paths/users/userID/2fa/get.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"operationId": "getUser2faStatus",
|
||||
"summary": "Get user 2fa Status",
|
||||
"tags": ["users"],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "userID",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"required": true,
|
||||
"description": "User ID",
|
||||
"example": 2
|
||||
}
|
||||
],
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
backend/schema/paths/users/userID/2fa/post.json
Normal file
52
backend/schema/paths/users/userID/2fa/post.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"operationId": "setupUser2fa",
|
||||
"summary": "Start 2FA setup, returns QR code URL",
|
||||
"tags": ["users"],
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "userID",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"required": true,
|
||||
"description": "User ID",
|
||||
"example": 2
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# Two-Factor Authentication Implementation
|
||||
|
||||
> **Note:** This document should be deleted after PR approval. It serves as a reference for reviewers to understand the scope of the contribution.
|
||||
|
||||
---
|
||||
|
||||
**Acknowledgments**
|
||||
|
||||
Thanks to all contributors and authors from the Inte.Team for the great work on Nginx Proxy Manager. It saves us time and effort, and we're happy to contribute back to the project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Add TOTP-based two-factor authentication to the login flow. Users can enable 2FA from their profile settings, scan a QR code with any authenticator app (Google Authenticator, Authy, etc.), and will be required to enter a 6-digit code on login.
|
||||
|
||||
## Current Authentication Flow
|
||||
|
||||
```
|
||||
POST /tokens {identity, secret}
|
||||
-> Validate user exists and is not disabled
|
||||
-> Verify password against auth.secret
|
||||
-> Return JWT token
|
||||
```
|
||||
|
||||
## Proposed 2FA Flow
|
||||
|
||||
```
|
||||
POST /tokens {identity, secret}
|
||||
-> Validate user exists and is not disabled
|
||||
-> Verify password against auth.secret
|
||||
-> If 2FA enabled:
|
||||
Return {requires_2fa: true, challenge_token: <short-lived JWT>}
|
||||
-> Else:
|
||||
Return {token: <JWT>, expires: <timestamp>}
|
||||
|
||||
POST /tokens/2fa {challenge_token, code}
|
||||
-> Validate challenge_token
|
||||
-> Verify TOTP code against user's secret
|
||||
-> Return {token: <JWT>, expires: <timestamp>}
|
||||
```
|
||||
|
||||
## Database Changes
|
||||
|
||||
Extend the existing `auth.meta` JSON column to store 2FA data:
|
||||
|
||||
```json
|
||||
{
|
||||
"totp_secret": "<encrypted-secret>",
|
||||
"totp_enabled": true,
|
||||
"totp_enabled_at": "<timestamp>",
|
||||
"backup_codes": ["<hashed-code-1>", "<hashed-code-2>", ...]
|
||||
}
|
||||
```
|
||||
|
||||
No new tables required. The `auth.meta` column is already designed for this purpose.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### New Files
|
||||
|
||||
1. `backend/internal/2fa.js` - Core 2FA logic
|
||||
- `generateSecret()` - Generate TOTP secret
|
||||
- `generateQRCodeURL(user, secret)` - Generate otpauth URL
|
||||
- `verifyCode(secret, code)` - Verify TOTP code
|
||||
- `generateBackupCodes()` - Generate 8 backup codes
|
||||
- `verifyBackupCode(user, code)` - Verify and consume backup code
|
||||
|
||||
2. `backend/routes/2fa.js` - 2FA management endpoints
|
||||
- `GET /users/:id/2fa` - Get 2FA status
|
||||
- `POST /users/:id/2fa/setup` - Start 2FA setup, return QR code
|
||||
- `PUT /users/:id/2fa/enable` - Verify code and enable 2FA
|
||||
- `DELETE /users/:id/2fa` - Disable 2FA (requires code)
|
||||
- `GET /users/:id/2fa/backup-codes` - View remaining backup codes count
|
||||
- `POST /users/:id/2fa/backup-codes` - Regenerate backup codes
|
||||
|
||||
### Modified Files
|
||||
|
||||
1. `backend/internal/token.js`
|
||||
- Update `getTokenFromEmail()` to check for 2FA
|
||||
- Add `verifyTwoFactorChallenge()` function
|
||||
- Add `createChallengeToken()` for short-lived 2FA tokens
|
||||
|
||||
2. `backend/routes/tokens.js`
|
||||
- Add `POST /tokens/2fa` endpoint
|
||||
|
||||
3. `backend/index.js`
|
||||
- Register new 2FA routes
|
||||
|
||||
### Dependencies
|
||||
|
||||
Add to `package.json`:
|
||||
```json
|
||||
"otplib": "^12.0.1"
|
||||
```
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### New Files
|
||||
|
||||
1. `frontend/src/pages/Login2FA/index.tsx` - 2FA code entry page
|
||||
2. `frontend/src/modals/TwoFactorSetupModal.tsx` - Setup wizard modal
|
||||
3. `frontend/src/api/backend/twoFactor.ts` - 2FA API functions
|
||||
4. `frontend/src/api/backend/verify2FA.ts` - Token verification
|
||||
|
||||
### Modified Files
|
||||
|
||||
1. `frontend/src/api/backend/responseTypes.ts`
|
||||
- Add `TwoFactorChallengeResponse` type
|
||||
- Add `TwoFactorStatusResponse` type
|
||||
|
||||
2. `frontend/src/context/AuthContext.tsx`
|
||||
- Add `twoFactorRequired` state
|
||||
- Add `challengeToken` state
|
||||
- Update `login()` to handle 2FA response
|
||||
- Add `verify2FA()` function
|
||||
|
||||
3. `frontend/src/pages/Login/index.tsx`
|
||||
- Handle 2FA challenge response
|
||||
- Redirect to 2FA entry when required
|
||||
|
||||
4. `frontend/src/pages/Settings/` (or user profile)
|
||||
- Add 2FA enable/disable section
|
||||
|
||||
### Dependencies
|
||||
|
||||
Add to `package.json`:
|
||||
```json
|
||||
"qrcode.react": "^3.1.0"
|
||||
```
|
||||
|
||||
## API Endpoints Summary
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| POST | /tokens | No | Login (returns challenge if 2FA) |
|
||||
| POST | /tokens/2fa | Challenge | Complete 2FA login |
|
||||
| GET | /users/:id/2fa | JWT | Get 2FA status |
|
||||
| POST | /users/:id/2fa/setup | JWT | Start setup, get QR code |
|
||||
| PUT | /users/:id/2fa/enable | JWT | Verify and enable |
|
||||
| DELETE | /users/:id/2fa | JWT | Disable (requires code) |
|
||||
| GET | /users/:id/2fa/backup-codes | JWT | Get backup codes count |
|
||||
| POST | /users/:id/2fa/backup-codes | JWT | Regenerate codes |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. Challenge tokens expire in 5 minutes
|
||||
2. TOTP secrets encrypted at rest
|
||||
3. Backup codes hashed with bcrypt
|
||||
4. Rate limit on 2FA attempts (5 attempts, 15 min lockout)
|
||||
5. Backup codes single-use only
|
||||
6. 2FA disable requires valid TOTP code
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Backend: Add `otplib` dependency
|
||||
2. Backend: Create `internal/2fa.js` module
|
||||
3. Backend: Update `internal/token.js` for challenge flow
|
||||
4. Backend: Add `POST /tokens/2fa` route
|
||||
5. Backend: Create `routes/2fa.js` for management
|
||||
6. Frontend: Add `qrcode.react` dependency
|
||||
7. Frontend: Update API types and functions
|
||||
8. Frontend: Update AuthContext for 2FA state
|
||||
9. Frontend: Create Login2FA page
|
||||
10. Frontend: Update Login to handle 2FA
|
||||
11. Frontend: Add 2FA settings UI
|
||||
|
||||
## Testing
|
||||
|
||||
1. Enable 2FA for user
|
||||
2. Login with password only - should get challenge
|
||||
3. Submit correct TOTP - should get token
|
||||
4. Submit wrong TOTP - should fail
|
||||
5. Use backup code - should work once
|
||||
6. Disable 2FA - should require valid code
|
||||
7. Login after disable - should work without 2FA
|
||||
@@ -156,7 +156,6 @@ export async function del({ url, params }: DeleteArgs, abortController?: AbortCo
|
||||
const method = "DELETE";
|
||||
const headers = {
|
||||
...buildAuthHeader(),
|
||||
[contentTypeHeader]: "application/json",
|
||||
};
|
||||
const signal = abortController?.signal;
|
||||
const response = await fetch(apiUrl, { method, headers, signal });
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { camelizeKeys, decamelizeKeys } from "humps";
|
||||
import AuthStore from "src/modules/AuthStore";
|
||||
import type {
|
||||
TwoFactorEnableResponse,
|
||||
TwoFactorSetupResponse,
|
||||
TwoFactorStatusResponse,
|
||||
} from "./responseTypes";
|
||||
import * as api from "./base";
|
||||
import type { TwoFactorEnableResponse, TwoFactorSetupResponse, TwoFactorStatusResponse } from "./responseTypes";
|
||||
|
||||
export async function get2FAStatus(userId: number | "me"): Promise<TwoFactorStatusResponse> {
|
||||
return await api.get({
|
||||
@@ -15,42 +9,27 @@ export async function get2FAStatus(userId: number | "me"): Promise<TwoFactorStat
|
||||
|
||||
export async function start2FASetup(userId: number | "me"): Promise<TwoFactorSetupResponse> {
|
||||
return await api.post({
|
||||
url: `/users/${userId}/2fa/setup`,
|
||||
url: `/users/${userId}/2fa`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function enable2FA(userId: number | "me", code: string): Promise<TwoFactorEnableResponse> {
|
||||
return await api.put({
|
||||
return await api.post({
|
||||
url: `/users/${userId}/2fa/enable`,
|
||||
data: { code },
|
||||
});
|
||||
}
|
||||
|
||||
export async function disable2FA(userId: number | "me", code: string): Promise<{ success: boolean }> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (AuthStore.token) {
|
||||
headers.Authorization = `Bearer ${AuthStore.token.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/users/${userId}/2fa`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
body: JSON.stringify(decamelizeKeys({ code })),
|
||||
export async function disable2FA(userId: number | "me", code: string): Promise<boolean> {
|
||||
return await api.del({
|
||||
url: `/users/${userId}/2fa`,
|
||||
params: {
|
||||
code,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error?.messageI18n || payload.error?.message || "Failed to disable 2FA");
|
||||
}
|
||||
return camelizeKeys(payload) as { success: boolean };
|
||||
}
|
||||
|
||||
export async function regenerateBackupCodes(
|
||||
userId: number | "me",
|
||||
code: string,
|
||||
): Promise<TwoFactorEnableResponse> {
|
||||
export async function regenerateBackupCodes(userId: number | "me", code: string): Promise<TwoFactorEnableResponse> {
|
||||
return await api.post({
|
||||
url: `/users/${userId}/2fa/backup-codes`,
|
||||
data: { code },
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"access-list": {
|
||||
"defaultMessage": "Lista dostępu"
|
||||
"defaultMessage": "wpis listy dostępu"
|
||||
},
|
||||
"access-list.access-count": {
|
||||
"defaultMessage": "{count} {count, plural, one {Reguła} few {Reguły} other {Reguł}}"
|
||||
},
|
||||
"access-list.auth-count": {
|
||||
"defaultMessage": "{count} {count, plural, one {Użytkownik} few {Użytkowników} other {Użytkowników}}"
|
||||
"defaultMessage": "{count} {count, plural, one {Użytkownik} few {Użytkownicy} other {Użytkowników}}"
|
||||
},
|
||||
"access-list.help-rules-last": {
|
||||
"defaultMessage": "Gdy istnieje co najmniej 1 reguła, ta reguła blokująca wszystko zostanie dodana na końcu"
|
||||
@@ -38,12 +38,18 @@
|
||||
"action.add-location": {
|
||||
"defaultMessage": "Dodaj lokalizację"
|
||||
},
|
||||
"action.allow": {
|
||||
"defaultMessage": "Zezwól"
|
||||
},
|
||||
"action.close": {
|
||||
"defaultMessage": "Zamknij"
|
||||
},
|
||||
"action.delete": {
|
||||
"defaultMessage": "Usuń"
|
||||
},
|
||||
"action.deny": {
|
||||
"defaultMessage": "Odrzuć"
|
||||
},
|
||||
"action.disable": {
|
||||
"defaultMessage": "Wyłącz"
|
||||
},
|
||||
@@ -66,13 +72,13 @@
|
||||
"defaultMessage": "Pokaż szczegóły"
|
||||
},
|
||||
"auditlogs": {
|
||||
"defaultMessage": "Logi audytu"
|
||||
"defaultMessage": "Logi"
|
||||
},
|
||||
"cancel": {
|
||||
"defaultMessage": "Anuluj"
|
||||
},
|
||||
"certificate": {
|
||||
"defaultMessage": "Certyfikat"
|
||||
"defaultMessage": "certyfikat"
|
||||
},
|
||||
"certificate.custom-certificate": {
|
||||
"defaultMessage": "Certyfikat"
|
||||
@@ -105,7 +111,7 @@
|
||||
"defaultMessage": "Certyfikaty"
|
||||
},
|
||||
"certificates.custom": {
|
||||
"defaultMessage": "Certyfikat własny"
|
||||
"defaultMessage": "Własny certyfikat"
|
||||
},
|
||||
"certificates.custom.warning": {
|
||||
"defaultMessage": "Pliki kluczy chronione hasłem nie są obsługiwane."
|
||||
@@ -153,7 +159,7 @@
|
||||
"defaultMessage": "Wyniki testu"
|
||||
},
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "Te domeny muszą być już skonfigurowane tak, aby wskazywały na ten serwer www"
|
||||
"defaultMessage": "Te domeny muszą być już skonfigurowane tak, aby wskazywały na ten serwer"
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "Typ klucza"
|
||||
@@ -171,7 +177,7 @@
|
||||
"defaultMessage": "z Let's Encrypt"
|
||||
},
|
||||
"certificates.request.title": {
|
||||
"defaultMessage": "Zamów nowy certyfikat"
|
||||
"defaultMessage": "Wygeneruj nowy certyfikat"
|
||||
},
|
||||
"column.access": {
|
||||
"defaultMessage": "Dostęp"
|
||||
@@ -183,7 +189,7 @@
|
||||
"defaultMessage": "Autoryzacje"
|
||||
},
|
||||
"column.custom-locations": {
|
||||
"defaultMessage": "Własne lokalizacje"
|
||||
"defaultMessage": "Własne ustawienia lokalizacji"
|
||||
},
|
||||
"column.destination": {
|
||||
"defaultMessage": "Cel"
|
||||
@@ -249,13 +255,13 @@
|
||||
"defaultMessage": "Panel"
|
||||
},
|
||||
"dead-host": {
|
||||
"defaultMessage": "Host 404"
|
||||
"defaultMessage": "host 404"
|
||||
},
|
||||
"dead-hosts": {
|
||||
"defaultMessage": "Hosty 404"
|
||||
"defaultMessage": "404"
|
||||
},
|
||||
"dead-hosts.count": {
|
||||
"defaultMessage": "{count} {count, plural, one {Host 404} few {Hosty 404} other {Hostów 404}}"
|
||||
"defaultMessage": "{count} {count, plural, one {host 404} few {hosty 404} other {hostów 404}}"
|
||||
},
|
||||
"disabled": {
|
||||
"defaultMessage": "Wyłączone"
|
||||
@@ -279,7 +285,7 @@
|
||||
"defaultMessage": "Wymuś SSL"
|
||||
},
|
||||
"domains.hsts-enabled": {
|
||||
"defaultMessage": "HSTS włączone"
|
||||
"defaultMessage": "Włącz HSTS "
|
||||
},
|
||||
"domains.hsts-subdomains": {
|
||||
"defaultMessage": "HSTS dla subdomen"
|
||||
@@ -348,7 +354,7 @@
|
||||
"defaultMessage": "Blokuj typowe exploity"
|
||||
},
|
||||
"host.flags.cache-assets": {
|
||||
"defaultMessage": "Buforuj zasoby"
|
||||
"defaultMessage": "Buforuj zasoby statyczne (ang. cache)"
|
||||
},
|
||||
"host.flags.preserve-path": {
|
||||
"defaultMessage": "Zachowaj ścieżkę"
|
||||
@@ -360,7 +366,7 @@
|
||||
"defaultMessage": "Obsługa WebSockets"
|
||||
},
|
||||
"host.forward-port": {
|
||||
"defaultMessage": "Port przekierowania"
|
||||
"defaultMessage": "Port docelowy"
|
||||
},
|
||||
"host.forward-scheme": {
|
||||
"defaultMessage": "Schemat"
|
||||
@@ -501,22 +507,22 @@
|
||||
"defaultMessage": "Tylko utworzone elementy"
|
||||
},
|
||||
"proxy-host": {
|
||||
"defaultMessage": "Host proxy"
|
||||
"defaultMessage": "host proxy"
|
||||
},
|
||||
"proxy-host.forward-host": {
|
||||
"defaultMessage": "Przekieruj na hostname / IP"
|
||||
},
|
||||
"proxy-hosts": {
|
||||
"defaultMessage": "Hosty proxy"
|
||||
"defaultMessage": "Proxy"
|
||||
},
|
||||
"proxy-hosts.count": {
|
||||
"defaultMessage": "{count} {count, plural, one {Host proxy} few {Hosty proxy} other {Hostów proxy}}"
|
||||
"defaultMessage": "{count} {count, plural, one {host proxy} few {hosty proxy} many {hostów proxy} other {hostów proxy}}"
|
||||
},
|
||||
"public": {
|
||||
"defaultMessage": "Publiczne"
|
||||
},
|
||||
"redirection-host": {
|
||||
"defaultMessage": "Host przekierowania"
|
||||
"defaultMessage": "adres przekierowania"
|
||||
},
|
||||
"redirection-host.forward-domain": {
|
||||
"defaultMessage": "Domena docelowa"
|
||||
@@ -525,10 +531,10 @@
|
||||
"defaultMessage": "Kod HTTP"
|
||||
},
|
||||
"redirection-hosts": {
|
||||
"defaultMessage": "Hosty przekierowań"
|
||||
"defaultMessage": "Przekierowania"
|
||||
},
|
||||
"redirection-hosts.count": {
|
||||
"defaultMessage": "{count} {count, plural, one {Host przekierowania} few {Hosty przekierowań} other {Hostów przekierowań}}"
|
||||
"defaultMessage": "{count} {count, plural, one {przekierowanie} few {przekierowania} many {przekierowań} other {przekierowań}}"
|
||||
},
|
||||
"role.admin": {
|
||||
"defaultMessage": "Administrator"
|
||||
@@ -582,7 +588,7 @@
|
||||
"defaultMessage": "Certyfikat SSL"
|
||||
},
|
||||
"stream": {
|
||||
"defaultMessage": "Strumień"
|
||||
"defaultMessage": "strumień"
|
||||
},
|
||||
"stream.forward-host": {
|
||||
"defaultMessage": "Host docelowy"
|
||||
@@ -594,7 +600,7 @@
|
||||
"defaultMessage": "Strumienie"
|
||||
},
|
||||
"streams.count": {
|
||||
"defaultMessage": "{count} {count, plural, one {Strumień} few {Strumienie} other {Strumieni}}"
|
||||
"defaultMessage": "{count} {count, plural, one {strumień} few {strumienie} many {strumieni} other {strumieni}}"
|
||||
},
|
||||
"streams.tcp": {
|
||||
"defaultMessage": "TCP"
|
||||
@@ -606,13 +612,13 @@
|
||||
"defaultMessage": "Test"
|
||||
},
|
||||
"user": {
|
||||
"defaultMessage": "Użytkownik"
|
||||
"defaultMessage": "użytkownik"
|
||||
},
|
||||
"user.change-password": {
|
||||
"defaultMessage": "Zmień hasło"
|
||||
},
|
||||
"user.confirm-password": {
|
||||
"defaultMessage": "Potwierdź hasło"
|
||||
"defaultMessage": "Potwierdź nowe hasło"
|
||||
},
|
||||
"user.current-password": {
|
||||
"defaultMessage": "Aktualne hasło"
|
||||
@@ -621,7 +627,7 @@
|
||||
"defaultMessage": "Edytuj profil"
|
||||
},
|
||||
"user.full-name": {
|
||||
"defaultMessage": "Pełne imię i nazwisko"
|
||||
"defaultMessage": "Imię / Nazwisko"
|
||||
},
|
||||
"user.login-as": {
|
||||
"defaultMessage": "Zaloguj jako {name}"
|
||||
|
||||
@@ -1307,9 +1307,9 @@ glob-parent@^6.0.2:
|
||||
is-glob "^4.0.3"
|
||||
|
||||
glob@^10.4.5:
|
||||
version "10.5.0"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c"
|
||||
integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==
|
||||
version "10.4.5"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
|
||||
integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
|
||||
dependencies:
|
||||
foreground-child "^3.1.0"
|
||||
jackspeak "^3.1.2"
|
||||
|
||||
Reference in New Issue
Block a user