mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-31 08:04:54 +02:00
Merge branch 'dev' into clients-pops-dev
This commit is contained in:
commit
3dc79da2fa
55 changed files with 5261 additions and 4194 deletions
|
@ -56,6 +56,8 @@ export enum ActionsEnum {
|
|||
// removeUserAction = "removeUserAction",
|
||||
// removeUserSite = "removeUserSite",
|
||||
getOrgUser = "getOrgUser",
|
||||
updateUser = "updateUser",
|
||||
getUser = "getUser",
|
||||
setResourcePassword = "setResourcePassword",
|
||||
setResourcePincode = "setResourcePincode",
|
||||
setResourceWhitelist = "setResourceWhitelist",
|
||||
|
|
|
@ -132,6 +132,7 @@ export const users = pgTable("user", {
|
|||
}),
|
||||
passwordHash: varchar("passwordHash"),
|
||||
twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false),
|
||||
twoFactorSetupRequested: boolean("twoFactorSetupRequested").default(false),
|
||||
twoFactorSecret: varchar("twoFactorSecret"),
|
||||
emailVerified: boolean("emailVerified").notNull().default(false),
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
|
@ -560,6 +561,30 @@ export const roleClients = pgTable("roleClients", {
|
|||
.references(() => clients.clientId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const securityKeys = pgTable("webauthnCredentials", {
|
||||
credentialId: varchar("credentialId").primaryKey(),
|
||||
userId: varchar("userId").notNull().references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
publicKey: varchar("publicKey").notNull(),
|
||||
signCount: integer("signCount").notNull(),
|
||||
transports: varchar("transports"),
|
||||
name: varchar("name"),
|
||||
lastUsed: varchar("lastUsed").notNull(),
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
securityKeyName: varchar("securityKeyName")
|
||||
});
|
||||
|
||||
export const webauthnChallenge = pgTable("webauthnChallenge", {
|
||||
sessionId: varchar("sessionId").primaryKey(),
|
||||
challenge: varchar("challenge").notNull(),
|
||||
securityKeyName: varchar("securityKeyName"),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
|
|
|
@ -147,6 +147,9 @@ export const users = sqliteTable("user", {
|
|||
twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
twoFactorSetupRequested: integer("twoFactorSetupRequested", {
|
||||
mode: "boolean"
|
||||
}).default(false),
|
||||
twoFactorSecret: text("twoFactorSecret"),
|
||||
emailVerified: integer("emailVerified", { mode: "boolean" })
|
||||
.notNull()
|
||||
|
@ -157,6 +160,29 @@ export const users = sqliteTable("user", {
|
|||
.default(false)
|
||||
});
|
||||
|
||||
export const securityKeys = sqliteTable("webauthnCredentials", {
|
||||
credentialId: text("credentialId").primaryKey(),
|
||||
userId: text("userId").notNull().references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
publicKey: text("publicKey").notNull(),
|
||||
signCount: integer("signCount").notNull(),
|
||||
transports: text("transports"),
|
||||
name: text("name"),
|
||||
lastUsed: text("lastUsed").notNull(),
|
||||
dateCreated: text("dateCreated").notNull()
|
||||
});
|
||||
|
||||
export const webauthnChallenge = sqliteTable("webauthnChallenge", {
|
||||
sessionId: text("sessionId").primaryKey(),
|
||||
challenge: text("challenge").notNull(),
|
||||
securityKeyName: text("securityKeyName"),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
expiresAt: integer("expiresAt").notNull() // Unix timestamp
|
||||
});
|
||||
|
||||
export const newts = sqliteTable("newt", {
|
||||
newtId: text("id").primaryKey(),
|
||||
secretHash: text("secretHash").notNull(),
|
||||
|
|
|
@ -36,7 +36,7 @@ declare global {
|
|||
interface Request {
|
||||
apiKey?: ApiKey;
|
||||
user?: User;
|
||||
session?: Session;
|
||||
session: Session;
|
||||
userOrg?: UserOrg;
|
||||
apiKeyOrg?: ApiKeyOrg;
|
||||
userOrgRoleId?: number;
|
||||
|
|
10
server/lib/totp.ts
Normal file
10
server/lib/totp.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { alphabet, generateRandomString } from "oslo/crypto";
|
||||
|
||||
export 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;
|
||||
}
|
|
@ -6,9 +6,10 @@ export * from "./requestTotpSecret";
|
|||
export * from "./disable2fa";
|
||||
export * from "./verifyEmail";
|
||||
export * from "./requestEmailVerificationCode";
|
||||
export * from "./changePassword";
|
||||
export * from "./requestPasswordReset";
|
||||
export * from "./resetPassword";
|
||||
export * from "./checkResourceSession";
|
||||
export * from "./requestPasswordReset";
|
||||
export * from "./setServerAdmin";
|
||||
export * from "./initialSetupComplete";
|
||||
export * from "./initialSetupComplete";
|
||||
export * from "./changePassword";
|
||||
export * from "./checkResourceSession";
|
||||
export * from "./securityKey";
|
||||
|
|
|
@ -4,7 +4,7 @@ import {
|
|||
serializeSessionCookie
|
||||
} from "@server/auth/sessions/app";
|
||||
import { db } from "@server/db";
|
||||
import { users } from "@server/db";
|
||||
import { users, securityKeys } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
@ -21,10 +21,7 @@ import { UserType } from "@server/types/UserTypes";
|
|||
|
||||
export const loginBodySchema = z
|
||||
.object({
|
||||
email: z
|
||||
.string()
|
||||
.toLowerCase()
|
||||
.email(),
|
||||
email: z.string().toLowerCase().email(),
|
||||
password: z.string(),
|
||||
code: z.string().optional()
|
||||
})
|
||||
|
@ -35,10 +32,10 @@ export type LoginBody = z.infer<typeof loginBodySchema>;
|
|||
export type LoginResponse = {
|
||||
codeRequested?: boolean;
|
||||
emailVerificationRequired?: boolean;
|
||||
useSecurityKey?: boolean;
|
||||
twoFactorSetupRequired?: boolean;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function login(
|
||||
req: Request,
|
||||
res: Response,
|
||||
|
@ -109,6 +106,35 @@ export async function login(
|
|||
);
|
||||
}
|
||||
|
||||
// Check if user has security keys registered
|
||||
const userSecurityKeys = await db
|
||||
.select()
|
||||
.from(securityKeys)
|
||||
.where(eq(securityKeys.userId, existingUser.userId));
|
||||
|
||||
if (userSecurityKeys.length > 0) {
|
||||
return response<LoginResponse>(res, {
|
||||
data: { useSecurityKey: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Security key authentication required",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
existingUser.twoFactorSetupRequested &&
|
||||
!existingUser.twoFactorEnabled
|
||||
) {
|
||||
return response<LoginResponse>(res, {
|
||||
data: { twoFactorSetupRequired: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Two-factor authentication setup required",
|
||||
status: HttpCode.ACCEPTED
|
||||
});
|
||||
}
|
||||
|
||||
if (existingUser.twoFactorEnabled) {
|
||||
if (!code) {
|
||||
return response<{ codeRequested: boolean }>(res, {
|
||||
|
|
|
@ -7,17 +7,19 @@ import HttpCode from "@server/types/HttpCode";
|
|||
import { response } from "@server/lib";
|
||||
import { db } from "@server/db";
|
||||
import { User, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { createTOTPKeyURI } from "oslo/otp";
|
||||
import logger from "@server/logger";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
import config from "@server/lib/config";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export const requestTotpSecretBody = z
|
||||
.object({
|
||||
password: z.string()
|
||||
password: z.string(),
|
||||
email: z.string().email().optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
@ -44,9 +46,42 @@ export async function requestTotpSecret(
|
|||
);
|
||||
}
|
||||
|
||||
const { password } = parsedBody.data;
|
||||
const { password, email } = parsedBody.data;
|
||||
|
||||
const user = req.user as User;
|
||||
const { user: sessionUser, session: existingSession } = await verifySession(req);
|
||||
|
||||
let user: User | null = sessionUser;
|
||||
if (!existingSession) {
|
||||
if (!email) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Email is required for two-factor authentication setup"
|
||||
)
|
||||
);
|
||||
}
|
||||
const [res] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
and(eq(users.type, UserType.Internal), eq(users.email, email))
|
||||
);
|
||||
user = res;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"Username or password is incorrect"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
|
@ -58,7 +93,10 @@ export async function requestTotpSecret(
|
|||
}
|
||||
|
||||
try {
|
||||
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||
const validPassword = await verifyPassword(
|
||||
password,
|
||||
user.passwordHash!
|
||||
);
|
||||
if (!validPassword) {
|
||||
return next(unauthorized());
|
||||
}
|
||||
|
|
717
server/routers/auth/securityKey.ts
Normal file
717
server/routers/auth/securityKey.ts
Normal file
|
@ -0,0 +1,717 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { User, securityKeys, users, webauthnChallenge } from "@server/db";
|
||||
import { eq, and, lt } from "drizzle-orm";
|
||||
import { response } from "@server/lib";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse
|
||||
} from "@simplewebauthn/server";
|
||||
import type {
|
||||
GenerateRegistrationOptionsOpts,
|
||||
VerifyRegistrationResponseOpts,
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
VerifyAuthenticationResponseOpts,
|
||||
VerifiedRegistrationResponse,
|
||||
VerifiedAuthenticationResponse
|
||||
} from "@simplewebauthn/server";
|
||||
import type {
|
||||
AuthenticatorTransport,
|
||||
AuthenticatorTransportFuture,
|
||||
PublicKeyCredentialDescriptorJSON,
|
||||
PublicKeyCredentialDescriptorFuture
|
||||
} from "@simplewebauthn/types";
|
||||
import config from "@server/lib/config";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
import { verifyTotpCode } from "@server/auth/totp";
|
||||
|
||||
// The RP ID is the domain name of your application
|
||||
const rpID = (() => {
|
||||
const url = new URL(config.getRawConfig().app.dashboard_url);
|
||||
// For localhost, we must use 'localhost' without port
|
||||
if (url.hostname === 'localhost') {
|
||||
return 'localhost';
|
||||
}
|
||||
return url.hostname;
|
||||
})();
|
||||
|
||||
const rpName = "Pangolin";
|
||||
const origin = config.getRawConfig().app.dashboard_url;
|
||||
|
||||
// Database-based challenge storage (replaces in-memory storage)
|
||||
// Challenges are stored in the webauthnChallenge table with automatic expiration
|
||||
// This supports clustered deployments and persists across server restarts
|
||||
|
||||
// Clean up expired challenges every 5 minutes
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
await db
|
||||
.delete(webauthnChallenge)
|
||||
.where(lt(webauthnChallenge.expiresAt, now));
|
||||
logger.debug("Cleaned up expired security key challenges");
|
||||
} catch (error) {
|
||||
logger.error("Failed to clean up expired security key challenges", error);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// Helper functions for challenge management
|
||||
async function storeChallenge(sessionId: string, challenge: string, securityKeyName?: string, userId?: string) {
|
||||
const expiresAt = Date.now() + (5 * 60 * 1000); // 5 minutes
|
||||
|
||||
// Delete any existing challenge for this session
|
||||
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||
|
||||
// Insert new challenge
|
||||
await db.insert(webauthnChallenge).values({
|
||||
sessionId,
|
||||
challenge,
|
||||
securityKeyName,
|
||||
userId,
|
||||
expiresAt
|
||||
});
|
||||
}
|
||||
|
||||
async function getChallenge(sessionId: string) {
|
||||
const [challengeData] = await db
|
||||
.select()
|
||||
.from(webauthnChallenge)
|
||||
.where(eq(webauthnChallenge.sessionId, sessionId))
|
||||
.limit(1);
|
||||
|
||||
if (!challengeData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (challengeData.expiresAt < Date.now()) {
|
||||
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||
return null;
|
||||
}
|
||||
|
||||
return challengeData;
|
||||
}
|
||||
|
||||
async function clearChallenge(sessionId: string) {
|
||||
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||
}
|
||||
|
||||
export const registerSecurityKeyBody = z.object({
|
||||
name: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
code: z.string().optional()
|
||||
}).strict();
|
||||
|
||||
export const verifyRegistrationBody = z.object({
|
||||
credential: z.any()
|
||||
}).strict();
|
||||
|
||||
export const startAuthenticationBody = z.object({
|
||||
email: z.string().email().optional()
|
||||
}).strict();
|
||||
|
||||
export const verifyAuthenticationBody = z.object({
|
||||
credential: z.any()
|
||||
}).strict();
|
||||
|
||||
export const deleteSecurityKeyBody = z.object({
|
||||
password: z.string().min(1),
|
||||
code: z.string().optional()
|
||||
}).strict();
|
||||
|
||||
export async function startRegistration(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = registerSecurityKeyBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { name, password, code } = parsedBody.data;
|
||||
const user = req.user as User;
|
||||
|
||||
// Only allow internal users to use security keys
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Security keys are only available for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify password
|
||||
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||
if (!validPassword) {
|
||||
return next(unauthorized());
|
||||
}
|
||||
|
||||
// If user has 2FA enabled, require and verify the code
|
||||
if (user.twoFactorEnabled) {
|
||||
if (!code) {
|
||||
return response<{ codeRequested: boolean }>(res, {
|
||||
data: { codeRequested: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Two-factor authentication required",
|
||||
status: HttpCode.ACCEPTED
|
||||
});
|
||||
}
|
||||
|
||||
const validOTP = await verifyTotpCode(
|
||||
code,
|
||||
user.twoFactorSecret!,
|
||||
user.userId
|
||||
);
|
||||
|
||||
if (!validOTP) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"The two-factor code you entered is incorrect"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing security keys for user
|
||||
const existingSecurityKeys = await db
|
||||
.select()
|
||||
.from(securityKeys)
|
||||
.where(eq(securityKeys.userId, user.userId));
|
||||
|
||||
const excludeCredentials = existingSecurityKeys.map(key => ({
|
||||
id: new Uint8Array(Buffer.from(key.credentialId, 'base64')),
|
||||
type: 'public-key' as const,
|
||||
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransportFuture[] : undefined
|
||||
}));
|
||||
|
||||
const options: GenerateRegistrationOptionsOpts = {
|
||||
rpName,
|
||||
rpID,
|
||||
userID: user.userId,
|
||||
userName: user.email || user.username,
|
||||
attestationType: 'none',
|
||||
excludeCredentials,
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
}
|
||||
};
|
||||
|
||||
const registrationOptions = await generateRegistrationOptions(options);
|
||||
|
||||
// Store challenge in database
|
||||
await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId);
|
||||
|
||||
return response<typeof registrationOptions>(res, {
|
||||
data: registrationOptions,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Registration options generated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to start registration"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyRegistration(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = verifyRegistrationBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { credential } = parsedBody.data;
|
||||
const user = req.user as User;
|
||||
|
||||
// Only allow internal users to use security keys
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Security keys are only available for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get challenge from database
|
||||
const challengeData = await getChallenge(req.session.sessionId);
|
||||
|
||||
if (!challengeData) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"No challenge found in session or challenge expired"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: credential,
|
||||
expectedChallenge: challengeData.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false
|
||||
});
|
||||
|
||||
const { verified, registrationInfo } = verification;
|
||||
|
||||
if (!verified || !registrationInfo) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Verification failed"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Store the security key in the database
|
||||
await db.insert(securityKeys).values({
|
||||
credentialId: Buffer.from(registrationInfo.credentialID).toString('base64'),
|
||||
userId: user.userId,
|
||||
publicKey: Buffer.from(registrationInfo.credentialPublicKey).toString('base64'),
|
||||
signCount: registrationInfo.counter || 0,
|
||||
transports: credential.response.transports ? JSON.stringify(credential.response.transports) : null,
|
||||
name: challengeData.securityKeyName,
|
||||
lastUsed: new Date().toISOString(),
|
||||
dateCreated: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Clear challenge data
|
||||
await clearChallenge(req.session.sessionId);
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Security key registered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to verify registration"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSecurityKeys(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const user = req.user as User;
|
||||
|
||||
// Only allow internal users to use security keys
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Security keys are only available for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const userSecurityKeys = await db
|
||||
.select()
|
||||
.from(securityKeys)
|
||||
.where(eq(securityKeys.userId, user.userId));
|
||||
|
||||
return response<typeof userSecurityKeys>(res, {
|
||||
data: userSecurityKeys,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Security keys retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to retrieve security keys"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSecurityKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const { credentialId: encodedCredentialId } = req.params;
|
||||
const credentialId = decodeURIComponent(encodedCredentialId);
|
||||
const user = req.user as User;
|
||||
|
||||
const parsedBody = deleteSecurityKeyBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { password, code } = parsedBody.data;
|
||||
|
||||
// Only allow internal users to use security keys
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Security keys are only available for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify password
|
||||
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||
if (!validPassword) {
|
||||
return next(unauthorized());
|
||||
}
|
||||
|
||||
// If user has 2FA enabled, require and verify the code
|
||||
if (user.twoFactorEnabled) {
|
||||
if (!code) {
|
||||
return response<{ codeRequested: boolean }>(res, {
|
||||
data: { codeRequested: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Two-factor authentication required",
|
||||
status: HttpCode.ACCEPTED
|
||||
});
|
||||
}
|
||||
|
||||
const validOTP = await verifyTotpCode(
|
||||
code,
|
||||
user.twoFactorSecret!,
|
||||
user.userId
|
||||
);
|
||||
|
||||
if (!validOTP) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"The two-factor code you entered is incorrect"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(securityKeys)
|
||||
.where(and(
|
||||
eq(securityKeys.credentialId, credentialId),
|
||||
eq(securityKeys.userId, user.userId)
|
||||
));
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Security key deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to delete security key"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAuthentication(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = startAuthenticationBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { email } = parsedBody.data;
|
||||
|
||||
try {
|
||||
let allowCredentials: PublicKeyCredentialDescriptorFuture[] = [];
|
||||
let userId;
|
||||
|
||||
// If email is provided, get security keys for that specific user
|
||||
if (email) {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!user || user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid credentials"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
userId = user.userId;
|
||||
|
||||
const userSecurityKeys = await db
|
||||
.select()
|
||||
.from(securityKeys)
|
||||
.where(eq(securityKeys.userId, user.userId));
|
||||
|
||||
if (userSecurityKeys.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"No security keys registered for this user"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
allowCredentials = userSecurityKeys.map(key => ({
|
||||
id: new Uint8Array(Buffer.from(key.credentialId, 'base64')),
|
||||
type: 'public-key' as const,
|
||||
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransportFuture[] : undefined
|
||||
}));
|
||||
} else {
|
||||
// If no email provided, allow any security key (for resident key authentication)
|
||||
allowCredentials = [];
|
||||
}
|
||||
|
||||
const options: GenerateAuthenticationOptionsOpts = {
|
||||
rpID,
|
||||
allowCredentials,
|
||||
userVerification: 'preferred',
|
||||
};
|
||||
|
||||
const authenticationOptions = await generateAuthenticationOptions(options);
|
||||
|
||||
// Generate a temporary session ID for unauthenticated users
|
||||
const tempSessionId = email ? `temp_${email}_${Date.now()}` : `temp_${Date.now()}`;
|
||||
|
||||
// Store challenge in database
|
||||
await storeChallenge(tempSessionId, authenticationOptions.challenge, undefined, userId);
|
||||
|
||||
return response(res, {
|
||||
data: { ...authenticationOptions, tempSessionId },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Authentication options generated",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to generate authentication options"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyAuthentication(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = verifyAuthenticationBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { credential } = parsedBody.data;
|
||||
const tempSessionId = req.headers['x-temp-session-id'] as string;
|
||||
|
||||
if (!tempSessionId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Your session information is missing. This might happen if you've been inactive for too long or if your browser cleared temporary data. Please start the sign-in process again."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get challenge from database
|
||||
const challengeData = await getChallenge(tempSessionId);
|
||||
|
||||
if (!challengeData) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Your sign-in session has expired. For security reasons, you have 5 minutes to complete the authentication process. Please try signing in again."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Find the security key in database
|
||||
const credentialId = Buffer.from(credential.id, 'base64').toString('base64');
|
||||
const [securityKey] = await db
|
||||
.select()
|
||||
.from(securityKeys)
|
||||
.where(eq(securityKeys.credentialId, credentialId))
|
||||
.limit(1);
|
||||
|
||||
if (!securityKey) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"We couldn't verify your security key. This might happen if your device isn't compatible or if the security key was removed too quickly. Please try again and keep your security key connected until the process completes."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get the user
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.userId, securityKey.userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user || user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User not found or not authorized for security key authentication"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: credential,
|
||||
expectedChallenge: challengeData.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
authenticator: {
|
||||
credentialID: Buffer.from(securityKey.credentialId, 'base64'),
|
||||
credentialPublicKey: Buffer.from(securityKey.publicKey, 'base64'),
|
||||
counter: securityKey.signCount,
|
||||
transports: securityKey.transports ? JSON.parse(securityKey.transports) as AuthenticatorTransportFuture[] : undefined
|
||||
},
|
||||
requireUserVerification: false
|
||||
});
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
||||
if (!verified) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Authentication failed. This could happen if your security key wasn't recognized or was removed too early. Please ensure your security key is properly connected and try again."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Update sign count
|
||||
await db
|
||||
.update(securityKeys)
|
||||
.set({
|
||||
signCount: authenticationInfo.newCounter,
|
||||
lastUsed: new Date().toISOString()
|
||||
})
|
||||
.where(eq(securityKeys.credentialId, credentialId));
|
||||
|
||||
// Create session for the user
|
||||
const { createSession, generateSessionToken, serializeSessionCookie } = await import("@server/auth/sessions/app");
|
||||
const token = generateSessionToken();
|
||||
const session = await createSession(token, user.userId);
|
||||
const isSecure = req.protocol === "https";
|
||||
const cookie = serializeSessionCookie(
|
||||
token,
|
||||
isSecure,
|
||||
new Date(session.expiresAt)
|
||||
);
|
||||
|
||||
res.setHeader("Set-Cookie", cookie);
|
||||
|
||||
// Clear challenge data
|
||||
await clearChallenge(tempSessionId);
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Authentication successful",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to verify authentication"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -6,18 +6,22 @@ import HttpCode from "@server/types/HttpCode";
|
|||
import { response } from "@server/lib";
|
||||
import { db } from "@server/db";
|
||||
import { twoFactorBackupCodes, User, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { alphabet, generateRandomString } from "oslo/crypto";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
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";
|
||||
import { generateBackupCodes } from "@server/lib/totp";
|
||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
|
||||
export const verifyTotpBody = z
|
||||
.object({
|
||||
email: z.string().email().optional(),
|
||||
password: z.string().optional(),
|
||||
code: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
@ -45,38 +49,83 @@ export async function verifyTotp(
|
|||
);
|
||||
}
|
||||
|
||||
const { code } = parsedBody.data;
|
||||
|
||||
const user = req.user as User;
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is already enabled"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User has not requested two-factor authentication"
|
||||
)
|
||||
);
|
||||
}
|
||||
const { code, email, password } = parsedBody.data;
|
||||
|
||||
try {
|
||||
const { user: sessionUser, session: existingSession } =
|
||||
await verifySession(req);
|
||||
|
||||
let user: User | null = sessionUser;
|
||||
if (!existingSession) {
|
||||
if (!email || !password) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Email and password are required for two-factor authentication"
|
||||
)
|
||||
);
|
||||
}
|
||||
const [res] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.type, UserType.Internal),
|
||||
eq(users.email, email)
|
||||
)
|
||||
);
|
||||
user = res;
|
||||
|
||||
const validPassword = await verifyPassword(
|
||||
password,
|
||||
user.passwordHash!
|
||||
);
|
||||
if (!validPassword) {
|
||||
return next(unauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"Username or password is incorrect"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is already enabled"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User has not requested two-factor authentication"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const valid = await verifyTotpCode(
|
||||
code,
|
||||
user.twoFactorSecret,
|
||||
|
@ -89,7 +138,9 @@ export async function verifyTotp(
|
|||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.update(users)
|
||||
.set({ twoFactorEnabled: true })
|
||||
.set({
|
||||
twoFactorEnabled: true
|
||||
})
|
||||
.where(eq(users.userId, user.userId));
|
||||
|
||||
const backupCodes = await generateBackupCodes();
|
||||
|
@ -153,12 +204,3 @@ export async function verifyTotp(
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -535,6 +535,7 @@ unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
|
|||
unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
|
||||
|
||||
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
|
||||
authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser);
|
||||
authenticated.delete(
|
||||
"/user/:userId",
|
||||
verifyUserIsServerAdmin,
|
||||
|
@ -550,6 +551,12 @@ authenticated.put(
|
|||
|
||||
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||
|
||||
authenticated.post(
|
||||
"/user/:userId/2fa",
|
||||
verifyUserIsServerAdmin,
|
||||
user.updateUser2FA
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/users",
|
||||
verifyOrgAccess,
|
||||
|
@ -795,12 +802,8 @@ authRouter.post("/logout", auth.logout);
|
|||
authRouter.post("/newt/get-token", getNewtToken);
|
||||
authRouter.post("/olm/get-token", getOlmToken);
|
||||
|
||||
authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp);
|
||||
authRouter.post(
|
||||
"/2fa/request",
|
||||
verifySessionUserMiddleware,
|
||||
auth.requestTotpSecret
|
||||
);
|
||||
authRouter.post("/2fa/enable", auth.verifyTotp);
|
||||
authRouter.post("/2fa/request", auth.requestTotpSecret);
|
||||
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
|
||||
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
|
||||
|
||||
|
@ -874,3 +877,36 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
|
|||
|
||||
authRouter.put("/set-server-admin", auth.setServerAdmin);
|
||||
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
|
||||
|
||||
// Security Key routes
|
||||
authRouter.post(
|
||||
"/security-key/register/start",
|
||||
verifySessionUserMiddleware,
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // Allow 5 security key registrations per 15 minutes
|
||||
keyGenerator: (req) => `securityKeyRegister:${req.user?.userId}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only register ${5} security keys every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
}
|
||||
}),
|
||||
auth.startRegistration
|
||||
);
|
||||
authRouter.post("/security-key/register/verify", verifySessionUserMiddleware, auth.verifyRegistration);
|
||||
authRouter.post(
|
||||
"/security-key/authenticate/start",
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // Allow 10 authentication attempts per 15 minutes per IP
|
||||
keyGenerator: (req) => `securityKeyAuth:${req.ip}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only attempt security key authentication ${10} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
}
|
||||
}),
|
||||
auth.startAuthentication
|
||||
);
|
||||
authRouter.post("/security-key/authenticate/verify", auth.verifyAuthentication);
|
||||
authRouter.get("/security-key/list", verifySessionUserMiddleware, auth.listSecurityKeys);
|
||||
authRouter.delete("/security-key/:credentialId", verifySessionUserMiddleware, auth.deleteSecurityKey);
|
||||
|
|
|
@ -381,6 +381,20 @@ authenticated.get(
|
|||
user.getOrgUser
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/user/:userId/2fa",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateUser),
|
||||
user.updateUser2FA
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/user/:userId",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.getUser),
|
||||
user.adminGetUser
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/users",
|
||||
verifyApiKeyOrgAccess,
|
||||
|
|
94
server/routers/user/adminGetUser.ts
Normal file
94
server/routers/user/adminGetUser.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { idp, users } from "@server/db";
|
||||
import { eq } 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 { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const adminGetUserSchema = z
|
||||
.object({
|
||||
userId: z.string().min(1)
|
||||
})
|
||||
.strict();
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/user/{userId}",
|
||||
description: "Get a user by ID.",
|
||||
tags: [OpenAPITags.User],
|
||||
request: {
|
||||
params: adminGetUserSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
async function queryUser(userId: string) {
|
||||
const [user] = await db
|
||||
.select({
|
||||
userId: users.userId,
|
||||
email: users.email,
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
type: users.type,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
twoFactorSetupRequested: users.twoFactorSetupRequested,
|
||||
emailVerified: users.emailVerified,
|
||||
serverAdmin: users.serverAdmin,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId,
|
||||
dateCreated: users.dateCreated
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.where(eq(users.userId, userId))
|
||||
.limit(1);
|
||||
return user;
|
||||
}
|
||||
|
||||
export type AdminGetUserResponse = NonNullable<
|
||||
Awaited<ReturnType<typeof queryUser>>
|
||||
>;
|
||||
|
||||
export async function adminGetUser(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = adminGetUserSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID")
|
||||
);
|
||||
}
|
||||
const { userId } = parsedParams.data;
|
||||
|
||||
const user = await queryUser(userId);
|
||||
|
||||
if (!user) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`User with ID ${userId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<AdminGetUserResponse>(res, {
|
||||
data: user,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -37,7 +37,9 @@ async function queryUsers(limit: number, offset: number) {
|
|||
serverAdmin: users.serverAdmin,
|
||||
type: users.type,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId
|
||||
idpId: users.idpId,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
twoFactorSetupRequested: users.twoFactorSetupRequested
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
|
|
133
server/routers/user/adminUpdateUser2FA.ts
Normal file
133
server/routers/user/adminUpdateUser2FA.ts
Normal file
|
@ -0,0 +1,133 @@
|
|||
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 { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const updateUser2FAParamsSchema = z
|
||||
.object({
|
||||
userId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const updateUser2FABodySchema = z
|
||||
.object({
|
||||
twoFactorSetupRequested: z.boolean()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type UpdateUser2FAResponse = {
|
||||
userId: string;
|
||||
twoFactorRequested: boolean;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/user/{userId}/2fa",
|
||||
description: "Update a user's 2FA status.",
|
||||
tags: [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 } = parsedParams.data;
|
||||
const { twoFactorSetupRequested } = parsedBody.data;
|
||||
|
||||
// Verify the user exists in the organization
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length === 0) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
if (existingUser[0].type !== "internal") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(`Updating 2FA for user ${userId} to ${twoFactorSetupRequested}`);
|
||||
|
||||
if (twoFactorSetupRequested) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
twoFactorSetupRequested: true,
|
||||
})
|
||||
.where(eq(users.userId, userId));
|
||||
} else {
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
twoFactorSetupRequested: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: null
|
||||
})
|
||||
.where(eq(users.userId, userId));
|
||||
}
|
||||
|
||||
return response<UpdateUser2FAResponse>(res, {
|
||||
data: {
|
||||
userId: existingUser[0].userId,
|
||||
twoFactorRequested: twoFactorSetupRequested
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: `2FA ${twoFactorSetupRequested ? "enabled" : "disabled"} for user successfully`,
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -23,7 +23,8 @@ async function queryUser(orgId: string, userId: string) {
|
|||
roleId: userOrgs.roleId,
|
||||
roleName: roles.name,
|
||||
isOwner: userOrgs.isOwner,
|
||||
isAdmin: roles.isAdmin
|
||||
isAdmin: roles.isAdmin,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
})
|
||||
.from(userOrgs)
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
|
|
|
@ -7,6 +7,9 @@ export * from "./acceptInvite";
|
|||
export * from "./getOrgUser";
|
||||
export * from "./adminListUsers";
|
||||
export * from "./adminRemoveUser";
|
||||
export * from "./adminGetUser";
|
||||
export * from "./listInvitations";
|
||||
export * from "./removeInvitation";
|
||||
export * from "./createOrgUser";
|
||||
export * from "./adminUpdateUser2FA";
|
||||
export * from "./adminGetUser";
|
||||
|
|
|
@ -49,7 +49,8 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
|
|||
roleName: roles.name,
|
||||
isOwner: userOrgs.isOwner,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId
|
||||
idpId: users.idpId,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
|
|
|
@ -4,7 +4,7 @@ import semver from "semver";
|
|||
import { versionMigrations } from "../db/pg";
|
||||
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
|
||||
import path from "path";
|
||||
import m1 from "./scriptsSqlite/1.6.0";
|
||||
import m1 from "./scriptsPg/1.6.0";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue