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:
piotrfx
2025-12-28 11:52:38 +01:00
parent fec8b3b083
commit 427afa55b4
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 authModel from "../models/auth.js";
import TokenModel from "../models/token.js"; import TokenModel from "../models/token.js";
import userModel from "../models/user.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 = "Invalid email or password";
const ERROR_MESSAGE_INVALID_AUTH_I18N = "error.invalid-auth"; 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 { export default {
/** /**
@@ -59,6 +62,25 @@ export default {
throw new errs.AuthError(`Invalid scope: ${data.scope}`); 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 // Create a moment of the expiry expression
const expiry = parseDatePeriod(data.expiry); const expiry = parseDatePeriod(data.expiry);
if (expiry === null) { if (expiry === null) {
@@ -129,6 +151,65 @@ export default {
throw new error.AssertionFailedError("Existing token contained invalid user data"); 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 * @param {Object} user
* @returns {Promise} * @returns {Promise}

View File

@@ -30,6 +30,7 @@
"mysql2": "^3.15.3", "mysql2": "^3.15.3",
"node-rsa": "^1.1.1", "node-rsa": "^1.1.1",
"objection": "3.0.1", "objection": "3.0.1",
"otplib": "^12.0.1",
"path": "^0.12.7", "path": "^0.12.7",
"pg": "^8.16.3", "pg": "^8.16.3",
"proxy-agent": "^6.5.0", "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; export default router;

View File

@@ -1,4 +1,5 @@
import express from "express"; import express from "express";
import internal2FA from "../internal/2fa.js";
import internalUser from "../internal/user.js"; import internalUser from "../internal/user.js";
import Access from "../lib/access.js"; import Access from "../lib/access.js";
import { isCI } from "../lib/config.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; export default router;

View File

@@ -0,0 +1,176 @@
# 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

View File

@@ -1,9 +1,22 @@
import * as api from "./base"; import * as api from "./base";
import type { TokenResponse } from "./responseTypes"; import type { TokenResponse, TwoFactorChallengeResponse } from "./responseTypes";
export async function getToken(identity: string, secret: string): Promise<TokenResponse> { export type LoginResponse = TokenResponse | TwoFactorChallengeResponse;
export function isTwoFactorChallenge(response: LoginResponse): response is TwoFactorChallengeResponse {
return "requires2fa" in response && response.requires2fa === true;
}
export async function getToken(identity: string, secret: string): Promise<LoginResponse> {
return await api.post({ return await api.post({
url: "/tokens", url: "/tokens",
data: { identity, secret }, data: { identity, secret },
}); });
} }
export async function verify2FA(challengeToken: string, code: string): Promise<TokenResponse> {
return await api.post({
url: "/tokens/2fa",
data: { challengeToken, code },
});
}

View File

@@ -60,3 +60,4 @@ export * from "./updateStream";
export * from "./updateUser"; export * from "./updateUser";
export * from "./uploadCertificate"; export * from "./uploadCertificate";
export * from "./validateCertificate"; export * from "./validateCertificate";
export * from "./twoFactor";

View File

@@ -25,3 +25,22 @@ export interface VersionCheckResponse {
latest: string | null; latest: string | null;
updateAvailable: boolean; updateAvailable: boolean;
} }
export interface TwoFactorChallengeResponse {
requires2fa: boolean;
challengeToken: string;
}
export interface TwoFactorStatusResponse {
enabled: boolean;
backupCodesRemaining: number;
}
export interface TwoFactorSetupResponse {
secret: string;
otpauthUrl: string;
}
export interface TwoFactorEnableResponse {
backupCodes: string[];
}

View File

@@ -0,0 +1,58 @@
import { camelizeKeys, decamelizeKeys } from "humps";
import AuthStore from "src/modules/AuthStore";
import type {
TwoFactorEnableResponse,
TwoFactorSetupResponse,
TwoFactorStatusResponse,
} from "./responseTypes";
import * as api from "./base";
export async function get2FAStatus(userId: number | "me"): Promise<TwoFactorStatusResponse> {
return await api.get({
url: `/users/${userId}/2fa`,
});
}
export async function start2FASetup(userId: number | "me"): Promise<TwoFactorSetupResponse> {
return await api.post({
url: `/users/${userId}/2fa/setup`,
});
}
export async function enable2FA(userId: number | "me", code: string): Promise<TwoFactorEnableResponse> {
return await api.put({
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 })),
});
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> {
return await api.post({
url: `/users/${userId}/2fa/backup-codes`,
data: { code },
});
}

View File

@@ -1,9 +1,9 @@
import { IconLock, IconLogout, IconUser } from "@tabler/icons-react"; import { IconLock, IconLogout, IconShieldLock, IconUser } from "@tabler/icons-react";
import { LocalePicker, NavLink, ThemeSwitcher } from "src/components"; import { LocalePicker, NavLink, ThemeSwitcher } from "src/components";
import { useAuthState } from "src/context"; import { useAuthState } from "src/context";
import { useUser } from "src/hooks"; import { useUser } from "src/hooks";
import { T } from "src/locale"; import { T } from "src/locale";
import { showChangePasswordModal, showUserModal } from "src/modals"; import { showChangePasswordModal, showTwoFactorModal, showUserModal } from "src/modals";
import styles from "./SiteHeader.module.css"; import styles from "./SiteHeader.module.css";
export function SiteHeader() { export function SiteHeader() {
@@ -108,6 +108,17 @@ export function SiteHeader() {
<IconLock width={18} /> <IconLock width={18} />
<T id="user.change-password" /> <T id="user.change-password" />
</a> </a>
<a
href="?"
className="dropdown-item"
onClick={(e) => {
e.preventDefault();
showTwoFactorModal("me");
}}
>
<IconShieldLock width={18} />
<T id="user.two-factor" />
</a>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<a <a
href="?" href="?"

View File

@@ -1,13 +1,28 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { createContext, type ReactNode, useContext, useState } from "react"; import { createContext, type ReactNode, useContext, useState } from "react";
import { useIntervalWhen } from "rooks"; import { useIntervalWhen } from "rooks";
import { getToken, loginAsUser, refreshToken, type TokenResponse } from "src/api/backend"; import {
getToken,
isTwoFactorChallenge,
loginAsUser,
refreshToken,
verify2FA,
type TokenResponse,
} from "src/api/backend";
import AuthStore from "src/modules/AuthStore"; import AuthStore from "src/modules/AuthStore";
// 2FA challenge state
export interface TwoFactorChallenge {
challengeToken: string;
}
// Context // Context
export interface AuthContextType { export interface AuthContextType {
authenticated: boolean; authenticated: boolean;
twoFactorChallenge: TwoFactorChallenge | null;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
verifyTwoFactor: (code: string) => Promise<void>;
cancelTwoFactor: () => void;
loginAs: (id: number) => Promise<void>; loginAs: (id: number) => Promise<void>;
logout: () => void; logout: () => void;
token?: string; token?: string;
@@ -24,17 +39,35 @@ interface Props {
function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) { function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [authenticated, setAuthenticated] = useState(AuthStore.hasActiveToken()); const [authenticated, setAuthenticated] = useState(AuthStore.hasActiveToken());
const [twoFactorChallenge, setTwoFactorChallenge] = useState<TwoFactorChallenge | null>(null);
const handleTokenUpdate = (response: TokenResponse) => { const handleTokenUpdate = (response: TokenResponse) => {
AuthStore.set(response); AuthStore.set(response);
setAuthenticated(true); setAuthenticated(true);
setTwoFactorChallenge(null);
}; };
const login = async (identity: string, secret: string) => { const login = async (identity: string, secret: string) => {
const response = await getToken(identity, secret); const response = await getToken(identity, secret);
if (isTwoFactorChallenge(response)) {
setTwoFactorChallenge({ challengeToken: response.challengeToken });
return;
}
handleTokenUpdate(response); handleTokenUpdate(response);
}; };
const verifyTwoFactor = async (code: string) => {
if (!twoFactorChallenge) {
throw new Error("No 2FA challenge pending");
}
const response = await verify2FA(twoFactorChallenge.challengeToken, code);
handleTokenUpdate(response);
};
const cancelTwoFactor = () => {
setTwoFactorChallenge(null);
};
const loginAs = async (id: number) => { const loginAs = async (id: number) => {
const response = await loginAsUser(id); const response = await loginAsUser(id);
AuthStore.add(response); AuthStore.add(response);
@@ -69,7 +102,15 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props)
true, true,
); );
const value = { authenticated, login, logout, loginAs }; const value = {
authenticated,
twoFactorChallenge,
login,
verifyTwoFactor,
cancelTwoFactor,
loginAs,
logout,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
} }

View File

@@ -1,4 +1,61 @@
{ {
"2fa.backup-codes-remaining": {
"defaultMessage": "Backup codes remaining: {count}"
},
"2fa.backup-warning": {
"defaultMessage": "Save these backup codes in a secure place. Each code can only be used once."
},
"2fa.disable": {
"defaultMessage": "Disable Two-Factor Authentication"
},
"2fa.disable-confirm": {
"defaultMessage": "Disable 2FA"
},
"2fa.disable-warning": {
"defaultMessage": "Disabling two-factor authentication will make your account less secure."
},
"2fa.disabled": {
"defaultMessage": "Disabled"
},
"2fa.done": {
"defaultMessage": "I have saved my backup codes"
},
"2fa.enable": {
"defaultMessage": "Enable Two-Factor Authentication"
},
"2fa.enabled": {
"defaultMessage": "Enabled"
},
"2fa.enter-code": {
"defaultMessage": "Enter verification code"
},
"2fa.enter-code-disable": {
"defaultMessage": "Enter verification code to disable"
},
"2fa.regenerate": {
"defaultMessage": "Regenerate"
},
"2fa.regenerate-backup": {
"defaultMessage": "Regenerate Backup Codes"
},
"2fa.regenerate-instructions": {
"defaultMessage": "Enter a verification code to generate new backup codes. Your old codes will be invalidated."
},
"2fa.secret-key": {
"defaultMessage": "Secret Key"
},
"2fa.setup-instructions": {
"defaultMessage": "Scan this QR code with your authenticator app, or enter the secret manually."
},
"2fa.status": {
"defaultMessage": "Status"
},
"2fa.title": {
"defaultMessage": "Two-Factor Authentication"
},
"2fa.verify-enable": {
"defaultMessage": "Verify and Enable"
},
"access-list": { "access-list": {
"defaultMessage": "Access List" "defaultMessage": "Access List"
}, },
@@ -386,6 +443,21 @@
"loading": { "loading": {
"defaultMessage": "Loading…" "defaultMessage": "Loading…"
}, },
"login.2fa-code": {
"defaultMessage": "Verification Code"
},
"login.2fa-code-placeholder": {
"defaultMessage": "Enter code"
},
"login.2fa-description": {
"defaultMessage": "Enter the code from your authenticator app"
},
"login.2fa-title": {
"defaultMessage": "Two-Factor Authentication"
},
"login.2fa-verify": {
"defaultMessage": "Verify"
},
"login.title": { "login.title": {
"defaultMessage": "Login to your account" "defaultMessage": "Login to your account"
}, },
@@ -674,6 +746,9 @@
"user.switch-light": { "user.switch-light": {
"defaultMessage": "Switch to Light mode" "defaultMessage": "Switch to Light mode"
}, },
"user.two-factor": {
"defaultMessage": "Two-Factor Auth"
},
"username": { "username": {
"defaultMessage": "Username" "defaultMessage": "Username"
}, },

View File

@@ -0,0 +1,368 @@
import EasyModal, { type InnerModalProps } from "ez-modal-react";
import { Field, Form, Formik } from "formik";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import {
disable2FA,
enable2FA,
get2FAStatus,
regenerateBackupCodes,
start2FASetup,
} from "src/api/backend";
import { Button } from "src/components";
import { T } from "src/locale";
import { validateString } from "src/modules/Validations";
type Step = "loading" | "status" | "setup" | "verify" | "backup" | "disable";
const showTwoFactorModal = (id: number | "me") => {
EasyModal.show(TwoFactorModal, { id });
};
interface Props extends InnerModalProps {
id: number | "me";
}
const TwoFactorModal = EasyModal.create(({ id, visible, remove }: Props) => {
const [error, setError] = useState<ReactNode | null>(null);
const [step, setStep] = useState<Step>("loading");
const [isEnabled, setIsEnabled] = useState(false);
const [backupCodesRemaining, setBackupCodesRemaining] = useState(0);
const [setupData, setSetupData] = useState<{ secret: string; otpauthUrl: string } | null>(null);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const loadStatus = useCallback(async () => {
try {
const status = await get2FAStatus(id);
setIsEnabled(status.enabled);
setBackupCodesRemaining(status.backupCodesRemaining);
setStep("status");
} catch (err: any) {
setError(err.message || "Failed to load 2FA status");
setStep("status");
}
}, [id]);
useEffect(() => {
loadStatus();
}, [loadStatus]);
const handleStartSetup = async () => {
setError(null);
setIsSubmitting(true);
try {
const data = await start2FASetup(id);
setSetupData(data);
setStep("setup");
} catch (err: any) {
setError(err.message || "Failed to start 2FA setup");
}
setIsSubmitting(false);
};
const handleVerify = async (values: { code: string }) => {
setError(null);
setIsSubmitting(true);
try {
const result = await enable2FA(id, values.code);
setBackupCodes(result.backupCodes);
setStep("backup");
} catch (err: any) {
setError(err.message || "Failed to enable 2FA");
}
setIsSubmitting(false);
};
const handleDisable = async (values: { code: string }) => {
setError(null);
setIsSubmitting(true);
try {
await disable2FA(id, values.code);
setIsEnabled(false);
setStep("status");
} catch (err: any) {
setError(err.message || "Failed to disable 2FA");
}
setIsSubmitting(false);
};
const handleRegenerateBackup = async (values: { code: string }) => {
setError(null);
setIsSubmitting(true);
try {
const result = await regenerateBackupCodes(id, values.code);
setBackupCodes(result.backupCodes);
setStep("backup");
} catch (err: any) {
setError(err.message || "Failed to regenerate backup codes");
}
setIsSubmitting(false);
};
const handleBackupDone = () => {
setIsEnabled(true);
setBackupCodes([]);
loadStatus();
};
const renderContent = () => {
if (step === "loading") {
return (
<div className="text-center py-4">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
);
}
if (step === "status") {
return (
<div className="py-2">
<div className="mb-4">
<div className="d-flex align-items-center justify-content-between mb-2">
<span className="fw-bold">
<T id="2fa.status" />
</span>
<span className={`badge text-white ${isEnabled ? "bg-success" : "bg-secondary"}`}>
{isEnabled ? <T id="2fa.enabled" /> : <T id="2fa.disabled" />}
</span>
</div>
{isEnabled && (
<p className="text-muted small mb-0">
<T id="2fa.backup-codes-remaining" data={{ count: backupCodesRemaining }} />
</p>
)}
</div>
{!isEnabled ? (
<Button
fullWidth
color="azure"
onClick={handleStartSetup}
isLoading={isSubmitting}
>
<T id="2fa.enable" />
</Button>
) : (
<div className="d-flex flex-column gap-2">
<Button fullWidth onClick={() => setStep("disable")}>
<T id="2fa.disable" />
</Button>
<Button fullWidth onClick={() => setStep("verify")}>
<T id="2fa.regenerate-backup" />
</Button>
</div>
)}
</div>
);
}
if (step === "setup" && setupData) {
return (
<div className="py-2">
<p className="text-muted mb-3">
<T id="2fa.setup-instructions" />
</p>
<div className="text-center mb-3">
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(setupData.otpauthUrl)}`}
alt="QR Code"
className="img-fluid"
style={{ maxWidth: "200px" }}
/>
</div>
<label className="mb-3 d-block">
<span className="form-label small text-muted">
<T id="2fa.secret-key" />
</span>
<input
type="text"
className="form-control font-monospace"
value={setupData.secret}
readOnly
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
</label>
<Formik initialValues={{ code: "" }} onSubmit={handleVerify}>
{() => (
<Form>
<Field name="code" validate={validateString(6, 6)}>
{({ field, form }: any) => (
<label className="mb-3 d-block">
<span className="form-label">
<T id="2fa.enter-code" />
</span>
<input
{...field}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
placeholder="000000"
maxLength={6}
/>
<div className="invalid-feedback">{form.errors.code}</div>
</label>
)}
</Field>
<div className="d-flex gap-2">
<Button
type="button"
fullWidth
onClick={() => setStep("status")}
disabled={isSubmitting}
>
<T id="cancel" />
</Button>
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
<T id="2fa.verify-enable" />
</Button>
</div>
</Form>
)}
</Formik>
</div>
);
}
if (step === "backup") {
return (
<div className="py-2">
<Alert variant="warning">
<T id="2fa.backup-warning" />
</Alert>
<div className="mb-3">
<div className="row g-2">
{backupCodes.map((code, index) => (
<div key={index} className="col-6">
<code className="d-block p-2 bg-light rounded text-center">{code}</code>
</div>
))}
</div>
</div>
<Button fullWidth color="azure" onClick={handleBackupDone}>
<T id="2fa.done" />
</Button>
</div>
);
}
if (step === "disable") {
return (
<div className="py-2">
<Alert variant="warning">
<T id="2fa.disable-warning" />
</Alert>
<Formik initialValues={{ code: "" }} onSubmit={handleDisable}>
{() => (
<Form>
<Field name="code" validate={validateString(6, 6)}>
{({ field, form }: any) => (
<label className="mb-3 d-block">
<span className="form-label">
<T id="2fa.enter-code-disable" />
</span>
<input
{...field}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
placeholder="000000"
maxLength={6}
/>
<div className="invalid-feedback">{form.errors.code}</div>
</label>
)}
</Field>
<div className="d-flex gap-2">
<Button
type="button"
fullWidth
onClick={() => setStep("status")}
disabled={isSubmitting}
>
<T id="cancel" />
</Button>
<Button type="submit" fullWidth color="red" isLoading={isSubmitting}>
<T id="2fa.disable-confirm" />
</Button>
</div>
</Form>
)}
</Formik>
</div>
);
}
if (step === "verify") {
return (
<div className="py-2">
<p className="text-muted mb-3">
<T id="2fa.regenerate-instructions" />
</p>
<Formik initialValues={{ code: "" }} onSubmit={handleRegenerateBackup}>
{() => (
<Form>
<Field name="code" validate={validateString(6, 6)}>
{({ field, form }: any) => (
<label className="mb-3 d-block">
<span className="form-label">
<T id="2fa.enter-code" />
</span>
<input
{...field}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
placeholder="000000"
maxLength={6}
/>
<div className="invalid-feedback">{form.errors.code}</div>
</label>
)}
</Field>
<div className="d-flex gap-2">
<Button
type="button"
fullWidth
onClick={() => setStep("status")}
disabled={isSubmitting}
>
<T id="cancel" />
</Button>
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
<T id="2fa.regenerate" />
</Button>
</div>
</Form>
)}
</Formik>
</div>
);
}
return null;
};
return (
<Modal show={visible} onHide={remove}>
<Modal.Header closeButton>
<Modal.Title>
<T id="2fa.title" />
</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
{error}
</Alert>
{renderContent()}
</Modal.Body>
</Modal>
);
});
export { showTwoFactorModal };

View File

@@ -13,4 +13,5 @@ export * from "./RedirectionHostModal";
export * from "./RenewCertificateModal"; export * from "./RenewCertificateModal";
export * from "./SetPasswordModal"; export * from "./SetPasswordModal";
export * from "./StreamModal"; export * from "./StreamModal";
export * from "./TwoFactorModal";
export * from "./UserModal"; export * from "./UserModal";

View File

@@ -8,8 +8,77 @@ import { intl, T } from "src/locale";
import { validateEmail, validateString } from "src/modules/Validations"; import { validateEmail, validateString } from "src/modules/Validations";
import styles from "./index.module.css"; import styles from "./index.module.css";
export default function Login() { function TwoFactorForm() {
const emailRef = useRef(null); const codeRef = useRef<HTMLInputElement>(null);
const [formErr, setFormErr] = useState("");
const { verifyTwoFactor, cancelTwoFactor } = useAuthState();
const onSubmit = async (values: any, { setSubmitting }: any) => {
setFormErr("");
try {
await verifyTwoFactor(values.code);
} catch (err) {
if (err instanceof Error) {
setFormErr(err.message);
}
}
setSubmitting(false);
};
useEffect(() => {
codeRef.current?.focus();
}, []);
return (
<>
<h2 className="h2 text-center mb-4">
<T id="login.2fa-title" />
</h2>
<p className="text-secondary text-center mb-4">
<T id="login.2fa-description" />
</p>
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
<Formik initialValues={{ code: "" }} onSubmit={onSubmit}>
{({ isSubmitting }) => (
<Form>
<div className="mb-3">
<Field name="code" validate={validateString(6, 20)}>
{({ field, form }: any) => (
<label className="form-label">
<T id="login.2fa-code" />
<input
{...field}
ref={codeRef}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
required
maxLength={20}
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "login.2fa-code-placeholder" })}
/>
<div className="invalid-feedback">{form.errors.code}</div>
</label>
)}
</Field>
</div>
<div className="form-footer d-flex gap-2">
<Button type="button" fullWidth onClick={cancelTwoFactor} disabled={isSubmitting}>
<T id="cancel" />
</Button>
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
<T id="login.2fa-verify" />
</Button>
</div>
</Form>
)}
</Formik>
</>
);
}
function LoginForm() {
const emailRef = useRef<HTMLInputElement>(null);
const [formErr, setFormErr] = useState(""); const [formErr, setFormErr] = useState("");
const { login } = useAuthState(); const { login } = useAuthState();
@@ -26,10 +95,79 @@ export default function Login() {
}; };
useEffect(() => { useEffect(() => {
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. emailRef.current?.focus();
emailRef.current.focus();
}, []); }, []);
return (
<>
<h2 className="h2 text-center mb-4">
<T id="login.title" />
</h2>
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
<Formik
initialValues={
{
email: "",
password: "",
} as any
}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
<Form>
<div className="mb-3">
<Field name="email" validate={validateEmail()}>
{({ field, form }: any) => (
<label className="form-label">
<T id="email-address" />
<input
{...field}
ref={emailRef}
type="email"
required
className={`form-control ${form.errors.email && form.touched.email ? " is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "email-address" })}
/>
<div className="invalid-feedback">{form.errors.email}</div>
</label>
)}
</Field>
</div>
<div className="mb-2">
<Field name="password" validate={validateString(8, 255)}>
{({ field, form }: any) => (
<>
<label className="form-label">
<T id="password" />
<input
{...field}
type="password"
autoComplete="current-password"
required
maxLength={255}
className={`form-control ${form.errors.password && form.touched.password ? " is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "password" })}
/>
<div className="invalid-feedback">{form.errors.password}</div>
</label>
</>
)}
</Field>
</div>
<div className="form-footer">
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
<T id="sign-in" />
</Button>
</div>
</Form>
)}
</Formik>
</>
);
}
export default function Login() {
const { twoFactorChallenge } = useAuthState();
const health = useHealth(); const health = useHealth();
const getVersion = () => { const getVersion = () => {
@@ -56,68 +194,7 @@ export default function Login() {
</div> </div>
<div className="card card-md"> <div className="card card-md">
<div className="card-body"> <div className="card-body">
<h2 className="h2 text-center mb-4"> {twoFactorChallenge ? <TwoFactorForm /> : <LoginForm />}
<T id="login.title" />
</h2>
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
<Formik
initialValues={
{
email: "",
password: "",
} as any
}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
<Form>
<div className="mb-3">
<Field name="email" validate={validateEmail()}>
{({ field, form }: any) => (
<label className="form-label">
<T id="email-address" />
<input
{...field}
ref={emailRef}
type="email"
required
className={`form-control ${form.errors.email && form.touched.email ? " is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "email-address" })}
/>
<div className="invalid-feedback">{form.errors.email}</div>
</label>
)}
</Field>
</div>
<div className="mb-2">
<Field name="password" validate={validateString(8, 255)}>
{({ field, form }: any) => (
<>
<label className="form-label">
<T id="password" />
<input
{...field}
type="password"
autoComplete="current-password"
required
maxLength={255}
className={`form-control ${form.errors.password && form.touched.password ? " is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "password" })}
/>
<div className="invalid-feedback">{form.errors.password}</div>
</label>
</>
)}
</Field>
</div>
<div className="form-footer">
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
<T id="sign-in" />
</Button>
</div>
</Form>
)}
</Formik>
</div> </div>
</div> </div>
<div className="text-center text-secondary mt-3">{getVersion()}</div> <div className="text-center text-secondary mt-3">{getVersion()}</div>