Admins can enable 2FA

Added the feature for admins to force 2FA on accounts. The next time the
user logs in they will have to setup 2FA on their account.
This commit is contained in:
J. Newing 2025-07-07 16:02:42 -04:00
parent f11fa4f32d
commit 2a6298e9eb
12 changed files with 843 additions and 20 deletions

16
package-lock.json generated
View file

@ -121,8 +121,7 @@
"tsc-alias": "1.8.16", "tsc-alias": "1.8.16",
"tsx": "4.20.3", "tsx": "4.20.3",
"typescript": "^5", "typescript": "^5",
"typescript-eslint": "^8.35.0", "typescript-eslint": "^8.35.0"
"yargs": "18.0.0"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@ -6174,7 +6173,6 @@
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"string-width": "^7.2.0", "string-width": "^7.2.0",
@ -6189,7 +6187,6 @@
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -6202,14 +6199,12 @@
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cliui/node_modules/string-width": { "node_modules/cliui/node_modules/string-width": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^10.3.0", "emoji-regex": "^10.3.0",
@ -6227,7 +6222,6 @@
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz",
"integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^6.2.1", "ansi-styles": "^6.2.1",
@ -7397,7 +7391,6 @@
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -8342,7 +8335,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@ -8352,7 +8344,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
"integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -16479,7 +16470,6 @@
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -16511,7 +16501,6 @@
"version": "18.0.0", "version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cliui": "^9.0.1", "cliui": "^9.0.1",
@ -16529,7 +16518,6 @@
"version": "22.0.0", "version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=23" "node": "^20.19.0 || ^22.12.0 || >=23"
@ -16539,14 +16527,12 @@
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/yargs/node_modules/string-width": { "node_modules/yargs/node_modules/string-width": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^10.3.0", "emoji-regex": "^10.3.0",

View file

@ -0,0 +1,179 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib";
import { db } from "@server/db";
import { twoFactorBackupCodes, users } from "@server/db";
import { eq, and } from "drizzle-orm";
import { alphabet, generateRandomString } from "oslo/crypto";
import { hashPassword, verifyPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/totp";
import logger from "@server/logger";
import { sendEmail } from "@server/emails";
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
export const completeTotpSetupBody = z
.object({
email: z.string().email(),
password: z.string(),
code: z.string()
})
.strict();
export type CompleteTotpSetupBody = z.infer<typeof completeTotpSetupBody>;
export type CompleteTotpSetupResponse = {
valid: boolean;
backupCodes?: string[];
};
export async function completeTotpSetup(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = completeTotpSetupBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { email, password, code } = parsedBody.data;
try {
// Find the user by email
const [user] = await db
.select()
.from(users)
.where(and(eq(users.email, email), eq(users.type, UserType.Internal)))
.limit(1);
if (!user) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Invalid credentials"
)
);
}
// Verify password
const validPassword = await verifyPassword(password, user.passwordHash!);
if (!validPassword) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Invalid credentials"
)
);
}
// Check if 2FA is enabled but not yet completed
if (!user.twoFactorEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not required for this user"
)
);
}
if (!user.twoFactorSecret) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User has not started two-factor authentication setup"
)
);
}
// Verify the TOTP code
const valid = await verifyTotpCode(
code,
user.twoFactorSecret,
user.userId
);
if (!valid) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Two-factor authentication code is incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid two-factor authentication code"
)
);
}
// Generate backup codes and finalize setup
let codes: string[] = [];
await db.transaction(async (trx) => {
// Note: We don't set twoFactorEnabled to true here because it's already true
// We just need to generate backup codes since the setup is now complete
const backupCodes = await generateBackupCodes();
codes = backupCodes;
for (const code of backupCodes) {
const hash = await hashPassword(code);
await trx.insert(twoFactorBackupCodes).values({
userId: user.userId,
codeHash: hash
});
}
});
// Send notification email
sendEmail(
TwoFactorAuthNotification({
email: user.email!,
enabled: true
}),
{
to: user.email!,
from: config.getRawConfig().email?.no_reply,
subject: "Two-factor authentication enabled"
}
);
return response<CompleteTotpSetupResponse>(res, {
data: {
valid: true,
backupCodes: codes
},
success: true,
error: false,
message: "Two-factor authentication setup completed successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to complete two-factor authentication setup"
)
);
}
}
async function generateBackupCodes(): Promise<string[]> {
const codes = [];
for (let i = 0; i < 10; i++) {
const code = generateRandomString(6, alphabet("0-9", "A-Z", "a-z"));
codes.push(code);
}
return codes;
}

View file

@ -3,6 +3,8 @@ export * from "./signup";
export * from "./logout"; export * from "./logout";
export * from "./verifyTotp"; export * from "./verifyTotp";
export * from "./requestTotpSecret"; export * from "./requestTotpSecret";
export * from "./setupTotpSecret";
export * from "./completeTotpSetup";
export * from "./disable2fa"; export * from "./disable2fa";
export * from "./verifyEmail"; export * from "./verifyEmail";
export * from "./requestEmailVerificationCode"; export * from "./requestEmailVerificationCode";

View file

@ -35,6 +35,7 @@ export type LoginBody = z.infer<typeof loginBodySchema>;
export type LoginResponse = { export type LoginResponse = {
codeRequested?: boolean; codeRequested?: boolean;
emailVerificationRequired?: boolean; emailVerificationRequired?: boolean;
twoFactorSetupRequired?: boolean;
}; };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -110,6 +111,17 @@ export async function login(
} }
if (existingUser.twoFactorEnabled) { if (existingUser.twoFactorEnabled) {
// If 2FA is enabled but no secret exists, force setup
if (!existingUser.twoFactorSecret) {
return response<LoginResponse>(res, {
data: { twoFactorSetupRequired: true },
success: true,
error: false,
message: "Two-factor authentication setup required",
status: HttpCode.ACCEPTED
});
}
if (!code) { if (!code) {
return response<{ codeRequested: boolean }>(res, { return response<{ codeRequested: boolean }>(res, {
data: { codeRequested: true }, data: { codeRequested: true },
@ -122,7 +134,7 @@ export async function login(
const validOTP = await verifyTotpCode( const validOTP = await verifyTotpCode(
code, code,
existingUser.twoFactorSecret!, existingUser.twoFactorSecret,
existingUser.userId existingUser.userId
); );

View file

@ -0,0 +1,127 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { encodeHex } from "oslo/encoding";
import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib";
import { db } from "@server/db";
import { User, users } from "@server/db";
import { eq, and } from "drizzle-orm";
import { createTOTPKeyURI } from "oslo/otp";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import { UserType } from "@server/types/UserTypes";
export const setupTotpSecretBody = z
.object({
email: z.string().email(),
password: z.string()
})
.strict();
export type SetupTotpSecretBody = z.infer<typeof setupTotpSecretBody>;
export type SetupTotpSecretResponse = {
secret: string;
uri: string;
};
export async function setupTotpSecret(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = setupTotpSecretBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { email, password } = parsedBody.data;
try {
// Find the user by email
const [user] = await db
.select()
.from(users)
.where(and(eq(users.email, email), eq(users.type, UserType.Internal)))
.limit(1);
if (!user) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Invalid credentials"
)
);
}
// Verify password
const validPassword = await verifyPassword(password, user.passwordHash!);
if (!validPassword) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Invalid credentials"
)
);
}
// Check if 2FA is enabled but no secret exists (forced setup scenario)
if (!user.twoFactorEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not required for this user"
)
);
}
if (user.twoFactorSecret) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User has already completed two-factor authentication setup"
)
);
}
// Generate new TOTP secret
const hex = crypto.getRandomValues(new Uint8Array(20));
const secret = encodeHex(hex);
const uri = createTOTPKeyURI("Pangolin", user.email!, hex);
// Save the secret to the database
await db
.update(users)
.set({
twoFactorSecret: secret
})
.where(eq(users.userId, user.userId));
return response<SetupTotpSecretResponse>(res, {
data: {
secret,
uri
},
success: true,
error: false,
message: "TOTP secret generated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to generate TOTP secret"
)
);
}
}

View file

@ -490,6 +490,13 @@ authenticated.put(
); );
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.patch(
"/org/:orgId/user/:userId/2fa",
verifyOrgAccess,
verifyUserAccess,
verifyUserHasAction(ActionsEnum.getOrgUser),
user.updateUser2FA
);
authenticated.get( authenticated.get(
"/org/:orgId/users", "/org/:orgId/users",
@ -718,6 +725,8 @@ authRouter.post(
verifySessionUserMiddleware, verifySessionUserMiddleware,
auth.requestTotpSecret auth.requestTotpSecret
); );
authRouter.post("/2fa/setup", auth.setupTotpSecret);
authRouter.post("/2fa/complete-setup", auth.completeTotpSetup);
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa); authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail); authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);

View file

@ -23,7 +23,8 @@ async function queryUser(orgId: string, userId: string) {
roleId: userOrgs.roleId, roleId: userOrgs.roleId,
roleName: roles.name, roleName: roles.name,
isOwner: userOrgs.isOwner, isOwner: userOrgs.isOwner,
isAdmin: roles.isAdmin isAdmin: roles.isAdmin,
twoFactorEnabled: users.twoFactorEnabled,
}) })
.from(userOrgs) .from(userOrgs)
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId))

View file

@ -10,3 +10,4 @@ export * from "./adminRemoveUser";
export * from "./listInvitations"; export * from "./listInvitations";
export * from "./removeInvitation"; export * from "./removeInvitation";
export * from "./createOrgUser"; export * from "./createOrgUser";
export * from "./updateUser2FA";

View file

@ -0,0 +1,151 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { users, userOrgs } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { OpenAPITags, registry } from "@server/openApi";
const updateUser2FAParamsSchema = z
.object({
userId: z.string(),
orgId: z.string()
})
.strict();
const updateUser2FABodySchema = z
.object({
twoFactorEnabled: z.boolean()
})
.strict();
export type UpdateUser2FAResponse = {
userId: string;
twoFactorEnabled: boolean;
};
registry.registerPath({
method: "patch",
path: "/org/{orgId}/user/{userId}/2fa",
description: "Update a user's 2FA status within an organization.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
params: updateUser2FAParamsSchema,
body: {
content: {
"application/json": {
schema: updateUser2FABodySchema
}
}
}
},
responses: {}
});
export async function updateUser2FA(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = updateUser2FAParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = updateUser2FABodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { userId, orgId } = parsedParams.data;
const { twoFactorEnabled } = parsedBody.data;
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You do not have access to this organization"
)
);
}
// Check if user has permission to update other users' 2FA
const hasPermission = await checkUserActionPermission(
ActionsEnum.getOrgUser,
req
);
if (!hasPermission) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission to update other users' 2FA settings"
)
);
}
// Verify the user exists in the organization
const existingUser = await db
.select()
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
if (existingUser.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found or does not belong to the specified organization"
)
);
}
// Update the user's 2FA status
const updatedUser = await db
.update(users)
.set({
twoFactorEnabled,
// If disabling 2FA, also clear the secret
twoFactorSecret: twoFactorEnabled ? undefined : null
})
.where(eq(users.userId, userId))
.returning({ userId: users.userId, twoFactorEnabled: users.twoFactorEnabled });
if (updatedUser.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found"
)
);
}
return response<UpdateUser2FAResponse>(res, {
data: updatedUser[0],
success: true,
error: false,
message: `2FA ${twoFactorEnabled ? 'enabled' : 'disabled'} for user successfully`,
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -27,6 +27,7 @@ import { ListRolesResponse } from "@server/routers/role";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import { import {
SettingsContainer, SettingsContainer,
SettingsSection, SettingsSection,
@ -43,7 +44,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
export default function AccessControlsPage() { export default function AccessControlsPage() {
const { orgUser: user } = userOrgUserContext(); const { orgUser: user, updateOrgUser } = userOrgUserContext();
console.log("User:", user);
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
@ -51,6 +54,7 @@ export default function AccessControlsPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [enable2FA, setEnable2FA] = useState(user.twoFactorEnabled || false);
const t = useTranslations(); const t = useTranslations();
@ -96,7 +100,8 @@ export default function AccessControlsPage() {
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true); setLoading(true);
const res = await api // Update user role
const roleRes = await api
.post< .post<
AxiosResponse<InviteUserResponse> AxiosResponse<InviteUserResponse>
>(`/role/${values.roleId}/add/${user.userId}`) >(`/role/${values.roleId}/add/${user.userId}`)
@ -109,9 +114,34 @@ export default function AccessControlsPage() {
t('accessRoleErrorAddDescription') t('accessRoleErrorAddDescription')
) )
}); });
return null;
}); });
if (res && res.status === 200) { // Update 2FA status if it changed
if (enable2FA !== user.twoFactorEnabled) {
const twoFARes = await api
.patch(`/org/${orgId}/user/${user.userId}/2fa`, {
twoFactorEnabled: enable2FA
})
.catch((e) => {
toast({
variant: "destructive",
title: "Error updating 2FA",
description: formatAxiosError(
e,
"Failed to update 2FA status"
)
});
return null;
});
if (twoFARes && twoFARes.status === 200) {
// Update the user context with the new 2FA status
updateOrgUser({ twoFactorEnabled: enable2FA });
}
}
if (roleRes && roleRes.status === 200) {
toast({ toast({
variant: "default", variant: "default",
title: t('userSaved'), title: t('userSaved'),
@ -170,6 +200,36 @@ export default function AccessControlsPage() {
</FormItem> </FormItem>
)} )}
/> />
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="enable-2fa"
checked={enable2FA}
onCheckedChange={(
e
) =>
setEnable2FA(
e as boolean
)
}
/>
<label
htmlFor="enable-2fa"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Enable 2FA for this user
</label>
</div>
<p className="text-xs text-muted-foreground ml-6">
When enabled, the user will be required to set up their authenticator app on their next login.
{user.twoFactorEnabled && (
<span className="text-blue-600"> This user currently has 2FA enabled.</span>
)}
</p>
</div>
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>

View file

@ -0,0 +1,289 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import {
RequestTotpSecretResponse,
VerifyTotpResponse
} from "@server/routers/auth";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas } from "qrcode.react";
import { useTranslations } from "next-intl";
export default function Setup2FAPage() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams?.get("redirect");
const email = searchParams?.get("email");
const [step, setStep] = useState(1);
const [secretKey, setSecretKey] = useState("");
const [secretUri, setSecretUri] = useState("");
const [loading, setLoading] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const api = createApiClient(useEnvContext());
const t = useTranslations();
// Redirect to login if no email is provided
useEffect(() => {
if (!email) {
router.push('/auth/login');
}
}, [email, router]);
const enableSchema = z.object({
password: z.string().min(1, { message: t('passwordRequired') })
});
const confirmSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') })
});
const enableForm = useForm<z.infer<typeof enableSchema>>({
resolver: zodResolver(enableSchema),
defaultValues: {
password: ""
}
});
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
resolver: zodResolver(confirmSchema),
defaultValues: {
code: ""
}
});
const request2fa = async (values: z.infer<typeof enableSchema>) => {
if (!email) return;
setLoading(true);
const res = await api
.post<AxiosResponse<RequestTotpSecretResponse>>(
`/auth/2fa/setup`,
{
email: email,
password: values.password
}
)
.catch((e) => {
toast({
title: t('otpErrorEnable'),
description: formatAxiosError(
e,
t('otpErrorEnableDescription')
),
variant: "destructive"
});
});
if (res && res.data.data.secret) {
setSecretKey(res.data.data.secret);
setSecretUri(res.data.data.uri);
setStep(2);
}
setLoading(false);
};
const confirm2fa = async (values: z.infer<typeof confirmSchema>) => {
if (!email) return;
setLoading(true);
const { password } = enableForm.getValues();
const res = await api
.post<AxiosResponse<VerifyTotpResponse>>(`/auth/2fa/complete-setup`, {
email: email,
password: password,
code: values.code
})
.catch((e) => {
toast({
title: t('otpErrorEnable'),
description: formatAxiosError(
e,
t('otpErrorEnableDescription')
),
variant: "destructive"
});
});
if (res && res.data.data.valid) {
setBackupCodes(res.data.data.backupCodes || []);
setStep(3);
}
setLoading(false);
};
const handleComplete = () => {
if (redirect) {
router.push(redirect);
} else {
router.push("/");
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t('otpSetup')}</CardTitle>
<CardDescription>
Your administrator has enabled two-factor authentication for <strong>{email}</strong>.
Please complete the setup process to continue.
</CardDescription>
</CardHeader>
<CardContent>
{step === 1 && (
<Form {...enableForm}>
<form
onSubmit={enableForm.handleSubmit(request2fa)}
className="space-y-4"
>
<div className="space-y-4">
<FormField
control={enableForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your current password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type="submit"
className="w-full"
loading={loading}
disabled={loading}
>
Continue
</Button>
</form>
</Form>
)}
{step === 2 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('otpSetupScanQr')}
</p>
<div className="flex justify-center">
<QRCodeCanvas value={secretUri} size={200} />
</div>
<div>
<Label className="text-xs text-muted-foreground">Manual entry key:</Label>
<CopyTextBox
text={secretKey}
wrapText={false}
/>
</div>
<Form {...confirmForm}>
<form
onSubmit={confirmForm.handleSubmit(confirm2fa)}
className="space-y-4"
>
<FormField
control={confirmForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('otpSetupSecretCode')}
</FormLabel>
<FormControl>
<Input
placeholder="Enter 6-digit code"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
loading={loading}
disabled={loading}
>
Verify and Complete Setup
</Button>
</form>
</Form>
</div>
)}
{step === 3 && (
<div className="space-y-4 text-center">
<CheckCircle2
className="mx-auto text-green-500"
size={48}
/>
<p className="font-semibold text-lg">
{t('otpSetupSuccess')}
</p>
<p className="text-sm text-muted-foreground">
{t('otpSetupSuccessStoreBackupCodes')}
</p>
{backupCodes.length > 0 && (
<div>
<Label className="text-xs text-muted-foreground">Backup codes:</Label>
<CopyTextBox text={backupCodes.join("\n")} />
</div>
)}
<Button
onClick={handleComplete}
className="w-full"
>
Continue to Application
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -134,6 +134,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
return; return;
} }
if (data?.twoFactorSetupRequired) {
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''}`;
router.push(setupUrl);
return;
}
if (onLogin) { if (onLogin) {
await onLogin(); await onLogin();
} }