mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-29 06:08:15 +02:00
Merge pull request #1035 from jnewing/feature-manage-2fa
Feature Manage Users 2fa
This commit is contained in:
commit
590296e64d
14 changed files with 880 additions and 8 deletions
179
server/routers/auth/completeTotpSetup.ts
Normal file
179
server/routers/auth/completeTotpSetup.ts
Normal 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;
|
||||||
|
}
|
|
@ -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";
|
||||||
|
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
127
server/routers/auth/setupTotpSecret.ts
Normal file
127
server/routers/auth/setupTotpSecret.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -49,7 +49,8 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
|
||||||
roleName: roles.name,
|
roleName: roles.name,
|
||||||
isOwner: userOrgs.isOwner,
|
isOwner: userOrgs.isOwner,
|
||||||
idpName: idp.name,
|
idpName: idp.name,
|
||||||
idpId: users.idpId
|
idpId: users.idpId,
|
||||||
|
twoFactorEnabled: users.twoFactorEnabled,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
|
151
server/routers/user/updateUser2FA.ts
Normal file
151
server/routers/user/updateUser2FA.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -34,6 +34,7 @@ export type UserRow = {
|
||||||
status: string;
|
status: string;
|
||||||
role: string;
|
role: string;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
|
isTwoFactorEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UsersTableProps = {
|
type UsersTableProps = {
|
||||||
|
@ -170,6 +171,39 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "isTwoFactorEnabled",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
2FA Enabled
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const userRow = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<span>{userRow.isTwoFactorEnabled && (
|
||||||
|
<span className="text-green-500">
|
||||||
|
{t('enabled')}
|
||||||
|
</span>
|
||||||
|
) || (
|
||||||
|
<span className="text-white/50">
|
||||||
|
{t('disabled')}
|
||||||
|
</span>
|
||||||
|
)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
|
|
|
@ -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,14 +44,14 @@ 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();
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const { orgId } = useParams();
|
const { orgId } = useParams();
|
||||||
|
|
||||||
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 +97,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 +111,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 +197,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-primary"> This user currently has 2FA enabled.</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
|
@ -186,6 +243,8 @@ export default function AccessControlsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,7 +81,8 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||||
idpName: user.idpName || t('idpNameInternal'),
|
idpName: user.idpName || t('idpNameInternal'),
|
||||||
status: t('userConfirmed'),
|
status: t('userConfirmed'),
|
||||||
role: user.isOwner ? t('accessRoleOwner') : user.roleName || t('accessRoleMember'),
|
role: user.isOwner ? t('accessRoleOwner') : user.roleName || t('accessRoleMember'),
|
||||||
isOwner: user.isOwner || false
|
isOwner: user.isOwner || false,
|
||||||
|
isTwoFactorEnabled: user.twoFactorEnabled || false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
289
src/app/auth/2fa/setup/page.tsx
Normal file
289
src/app/auth/2fa/setup/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue