afabaa0b7a
Adds lastTotpAt timestamp to User model. After a successful TOTP validation, the timestamp is recorded. Any reuse of the same code within the 30-second window is rejected as a replay attack. verifyTotp now returns a single generic UNAUTHORIZED error regardless of whether the user ID is invalid or TOTP is not enabled, preventing enumeration of user IDs and MFA status. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
303 lines
9.3 KiB
TypeScript
303 lines
9.3 KiB
TypeScript
import { Prisma } from "@capakraken/db";
|
|
import {
|
|
dashboardLayoutSchema,
|
|
normalizeDashboardLayout,
|
|
} from "@capakraken/shared/schemas";
|
|
import type { ColumnPreferences } from "@capakraken/shared/types";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
|
import { createAuditEntry } from "../lib/audit.js";
|
|
import { totpRateLimiter } from "../middleware/rate-limit.js";
|
|
import type { TRPCContext } from "../trpc.js";
|
|
|
|
export const SaveDashboardLayoutInputSchema = z.object({
|
|
layout: dashboardLayoutSchema,
|
|
});
|
|
|
|
export const ToggleFavoriteProjectInputSchema = z.object({
|
|
projectId: z.string(),
|
|
});
|
|
|
|
export const SetColumnPreferencesInputSchema = z.object({
|
|
view: z.enum(["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"]),
|
|
visible: z.array(z.string()).optional(),
|
|
sort: z.object({ field: z.string(), dir: z.enum(["asc", "desc"]) }).nullable().optional(),
|
|
rowOrder: z.array(z.string()).nullable().optional(),
|
|
});
|
|
|
|
export const VerifyAndEnableTotpInputSchema = z.object({
|
|
token: z.string().length(6),
|
|
});
|
|
|
|
export const VerifyTotpInputSchema = z.object({
|
|
userId: z.string(),
|
|
token: z.string().length(6),
|
|
});
|
|
|
|
type UserSelfServiceContext = Pick<TRPCContext, "db" | "dbUser" | "session">;
|
|
type UserPublicContext = Pick<TRPCContext, "db">;
|
|
|
|
export async function getCurrentUserProfile(ctx: UserSelfServiceContext) {
|
|
return findUniqueOrThrow(
|
|
ctx.db.user.findUnique({
|
|
where: { id: ctx.dbUser!.id },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
systemRole: true,
|
|
permissionOverrides: true,
|
|
createdAt: true,
|
|
},
|
|
}),
|
|
"User",
|
|
);
|
|
}
|
|
|
|
export async function getDashboardLayout(ctx: UserSelfServiceContext) {
|
|
const user = await ctx.db.user.findUnique({
|
|
where: { id: ctx.dbUser!.id },
|
|
select: { dashboardLayout: true, updatedAt: true },
|
|
});
|
|
|
|
const normalized = user?.dashboardLayout
|
|
? normalizeDashboardLayout(user.dashboardLayout)
|
|
: null;
|
|
return {
|
|
layout: normalized?.widgets.length ? normalized : null,
|
|
updatedAt: user?.updatedAt ?? null,
|
|
};
|
|
}
|
|
|
|
export async function saveDashboardLayout(
|
|
ctx: UserSelfServiceContext,
|
|
input: z.infer<typeof SaveDashboardLayoutInputSchema>,
|
|
) {
|
|
const updated = await ctx.db.user.update({
|
|
where: { id: ctx.dbUser!.id },
|
|
data: { dashboardLayout: input.layout as unknown as Prisma.InputJsonValue },
|
|
select: { updatedAt: true },
|
|
});
|
|
|
|
return { updatedAt: updated.updatedAt };
|
|
}
|
|
|
|
export async function getFavoriteProjectIds(ctx: UserSelfServiceContext) {
|
|
const user = await ctx.db.user.findUnique({
|
|
where: { id: ctx.dbUser!.id },
|
|
select: { favoriteProjectIds: true },
|
|
});
|
|
|
|
return ((user?.favoriteProjectIds as string[] | null) ?? []) as string[];
|
|
}
|
|
|
|
export async function toggleFavoriteProject(
|
|
ctx: UserSelfServiceContext,
|
|
input: z.infer<typeof ToggleFavoriteProjectInputSchema>,
|
|
) {
|
|
const user = await ctx.db.user.findUnique({
|
|
where: { id: ctx.dbUser!.id },
|
|
select: { favoriteProjectIds: true },
|
|
});
|
|
const current = ((user?.favoriteProjectIds as string[] | null) ?? []) as string[];
|
|
const next = current.includes(input.projectId)
|
|
? current.filter((id) => id !== input.projectId)
|
|
: [...current, input.projectId];
|
|
|
|
await ctx.db.user.update({
|
|
where: { id: ctx.dbUser!.id },
|
|
data: { favoriteProjectIds: next as unknown as Prisma.InputJsonValue },
|
|
});
|
|
|
|
return { favoriteProjectIds: next, added: !current.includes(input.projectId) };
|
|
}
|
|
|
|
export async function getColumnPreferences(ctx: UserSelfServiceContext) {
|
|
const user = await ctx.db.user.findUnique({
|
|
where: { id: ctx.dbUser!.id },
|
|
select: { columnPreferences: true },
|
|
});
|
|
|
|
return (user?.columnPreferences ?? {}) as ColumnPreferences;
|
|
}
|
|
|
|
export async function setColumnPreferences(
|
|
ctx: UserSelfServiceContext,
|
|
input: z.infer<typeof SetColumnPreferencesInputSchema>,
|
|
) {
|
|
const existing = await ctx.db.user.findUnique({
|
|
where: { id: ctx.dbUser!.id },
|
|
select: { columnPreferences: true },
|
|
});
|
|
const prefs = (existing?.columnPreferences ?? {}) as ColumnPreferences;
|
|
const prev = (prefs[input.view] as import("@capakraken/shared").ViewPreferences | undefined) ?? { visible: [] };
|
|
|
|
const merged: import("@capakraken/shared").ViewPreferences = {
|
|
visible: input.visible ?? prev.visible,
|
|
};
|
|
if (input.sort !== null && input.sort !== undefined) {
|
|
merged.sort = input.sort;
|
|
} else if (input.sort === undefined && prev.sort != null) {
|
|
merged.sort = prev.sort;
|
|
}
|
|
if (input.rowOrder !== null && input.rowOrder !== undefined) {
|
|
merged.rowOrder = input.rowOrder;
|
|
} else if (input.rowOrder === undefined && prev.rowOrder != null) {
|
|
merged.rowOrder = prev.rowOrder;
|
|
}
|
|
|
|
prefs[input.view] = merged;
|
|
await ctx.db.user.update({
|
|
where: { id: ctx.dbUser!.id },
|
|
data: { columnPreferences: prefs as Prisma.InputJsonValue },
|
|
});
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function generateTotpSecret(ctx: UserSelfServiceContext) {
|
|
const { TOTP, Secret } = await import("otpauth");
|
|
const secret = new Secret({ size: 20 });
|
|
const totp = new TOTP({
|
|
issuer: "CapaKraken",
|
|
label: ctx.session?.user?.email ?? ctx.dbUser!.id,
|
|
algorithm: "SHA1",
|
|
digits: 6,
|
|
period: 30,
|
|
secret,
|
|
});
|
|
|
|
await ctx.db.user.update({
|
|
where: { id: ctx.dbUser!.id },
|
|
data: { totpSecret: secret.base32 },
|
|
});
|
|
|
|
return { secret: secret.base32, uri: totp.toString() };
|
|
}
|
|
|
|
export async function verifyAndEnableTotp(
|
|
ctx: UserSelfServiceContext,
|
|
input: z.infer<typeof VerifyAndEnableTotpInputSchema>,
|
|
) {
|
|
const user = await findUniqueOrThrow(
|
|
ctx.db.user.findUnique({
|
|
where: { id: ctx.dbUser!.id },
|
|
select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true, lastTotpAt: true },
|
|
}) as Promise<{ id: string; name: string | null; email: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null>,
|
|
"User",
|
|
);
|
|
|
|
if (!user.totpSecret) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "No TOTP secret generated. Call generateTotpSecret first." });
|
|
}
|
|
if (user.totpEnabled) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is already enabled." });
|
|
}
|
|
|
|
const { TOTP, Secret } = await import("otpauth");
|
|
const totp = new TOTP({
|
|
issuer: "CapaKraken",
|
|
label: user.email,
|
|
algorithm: "SHA1",
|
|
digits: 6,
|
|
period: 30,
|
|
secret: Secret.fromBase32(user.totpSecret),
|
|
});
|
|
|
|
const delta = totp.validate({ token: input.token, window: 1 });
|
|
if (delta === null) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid TOTP token." });
|
|
}
|
|
|
|
// Replay-attack prevention: reject if the same 30-second window was already used
|
|
if (
|
|
user.lastTotpAt != null &&
|
|
Date.now() - user.lastTotpAt.getTime() < 30_000
|
|
) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP code already used. Wait for the next code." });
|
|
}
|
|
|
|
await (ctx.db.user.update as Function)({
|
|
where: { id: user.id },
|
|
data: { totpEnabled: true, lastTotpAt: new Date() },
|
|
});
|
|
|
|
void createAuditEntry({
|
|
db: ctx.db,
|
|
entityType: "User",
|
|
entityId: user.id,
|
|
entityName: `${user.name} (${user.email})`,
|
|
action: "UPDATE",
|
|
userId: user.id,
|
|
source: "ui",
|
|
summary: "Enabled TOTP MFA",
|
|
});
|
|
|
|
return { enabled: true };
|
|
}
|
|
|
|
export async function verifyTotp(
|
|
ctx: UserPublicContext,
|
|
input: z.infer<typeof VerifyTotpInputSchema>,
|
|
) {
|
|
// Rate limit: max 10 attempts per 30 seconds per userId to prevent brute-force (A01-1)
|
|
const rl = await totpRateLimiter(input.userId);
|
|
if (!rl.allowed) {
|
|
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many TOTP attempts. Please wait before trying again." });
|
|
}
|
|
|
|
const user = await ctx.db.user.findUnique({
|
|
where: { id: input.userId },
|
|
select: { id: true, totpSecret: true, totpEnabled: true, lastTotpAt: true },
|
|
}) as { id: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null;
|
|
|
|
// Generic error for both not-found and TOTP-not-enabled to prevent user enumeration
|
|
if (!user || !user.totpEnabled || !user.totpSecret) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
|
|
}
|
|
|
|
const { TOTP, Secret } = await import("otpauth");
|
|
const totp = new TOTP({
|
|
issuer: "CapaKraken",
|
|
label: user.id,
|
|
algorithm: "SHA1",
|
|
digits: 6,
|
|
period: 30,
|
|
secret: Secret.fromBase32(user.totpSecret),
|
|
});
|
|
|
|
const delta = totp.validate({ token: input.token, window: 1 });
|
|
if (delta === null) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
|
|
}
|
|
|
|
// Replay-attack prevention: reject if the same 30-second window was already used
|
|
if (
|
|
user.lastTotpAt != null &&
|
|
Date.now() - user.lastTotpAt.getTime() < 30_000
|
|
) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
|
|
}
|
|
|
|
// Record successful TOTP use to prevent replay within the same window
|
|
await (ctx.db.user.update as Function)({
|
|
where: { id: user.id },
|
|
data: { lastTotpAt: new Date() },
|
|
});
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
export async function getCurrentMfaStatus(ctx: UserSelfServiceContext) {
|
|
const user = await findUniqueOrThrow(
|
|
ctx.db.user.findUnique({
|
|
where: { id: ctx.dbUser!.id },
|
|
select: { totpEnabled: true },
|
|
}),
|
|
"User",
|
|
);
|
|
|
|
return { totpEnabled: user.totpEnabled };
|
|
}
|