Files
CapaKraken/packages/api/src/router/user-self-service-procedure-support.ts
T
Hartmut 3222bec8a5 security: atomic compare-and-swap for TOTP replay window (#43, part 1)
The previous SELECT → compare → UPDATE sequence let two concurrent login
requests with the same valid 6-digit code both observe a stale lastTotpAt,
both pass the in-JS replay check, and both succeed. A stolen TOTP (shoulder-
surf, phishing-proxy replay) was usable twice within its 30 s window.

Replace the three callsites (login authorize, self-service enable, self-
service verify) with a shared consumeTotpWindow() helper: a single
updateMany() expresses "window unused" as a SQL WHERE clause, so Postgres'
row lock serialises concurrent writers and whichever commits second sees
count=0 and is treated as a replay.

Backup codes (ticket part 2) are tracked as follow-up work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:11:50 +02:00

335 lines
9.6 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 { consumeTotpWindow } from "../lib/totp-consume.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" | "clientIp">;
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." });
}
// Atomic replay-guard: single UPDATE with WHERE-guard on lastTotpAt. See
// packages/api/src/lib/totp-consume.ts for rationale.
const accepted = await consumeTotpWindow(ctx.db, user.id);
if (!accepted) {
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 },
});
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 keyed on BOTH userId and source IP. userId-only keying
// permits targeted user-lockout DoS; IP-only permits botnet bypass.
// Both buckets must allow for the attempt to proceed (CWE-307, A01-1).
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
const totpKeys = ipKey ? [`user:${input.userId}`, ipKey] : [`user:${input.userId}`];
const rl = await totpRateLimiter(totpKeys);
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." });
}
// Atomic replay-guard — see packages/api/src/lib/totp-consume.ts.
const accepted = await consumeTotpWindow(ctx.db, user.id);
if (!accepted) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
}
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 };
}