e01074926e
CI / Architecture Guardrails (pull_request) Successful in 6m31s
CI / Typecheck (pull_request) Failing after 6m9s
CI / Build (pull_request) Has been skipped
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Successful in 7m23s
CI / Lint (pull_request) Successful in 6m54s
CI / Unit Tests (pull_request) Successful in 9m28s
CI / Release Images (pull_request) Has been skipped
Adds a synchronous policy check that blocks (1) the curated >=12-char common-password list (rockyou top, predictable seasonal, admin defaults), (2) trivial patterns (single-char repeat, short-pattern repeat, keyboard or numeric sequences), and (3) passwords containing the user's email local-part or any name component. Wired into all five password-mutation sites: first-admin setup, admin createUser/setUserPassword, invite acceptance, and password-reset. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
172 lines
6.0 KiB
TypeScript
172 lines
6.0 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import {
|
|
PASSWORD_MAX_LENGTH,
|
|
PASSWORD_MIN_LENGTH,
|
|
PASSWORD_POLICY_MESSAGE,
|
|
checkPasswordPolicy,
|
|
} from "@capakraken/shared";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
import { createTRPCRouter, publicProcedure } from "../trpc.js";
|
|
import { getAppBaseUrl } from "../lib/app-base-url.js";
|
|
import { sendEmail } from "../lib/email.js";
|
|
import { authRateLimiter } from "../middleware/rate-limit.js";
|
|
|
|
const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
|
|
function resetEmailHtml(resetUrl: string): string {
|
|
return `
|
|
<p>You requested a password reset for your CapaKraken account.</p>
|
|
<p>Click the link below to set a new password:</p>
|
|
<p><a href="${resetUrl}">${resetUrl}</a></p>
|
|
<p>This link expires in 1 hour and can only be used once.</p>
|
|
<p>If you did not request this, you can ignore this email.</p>
|
|
`;
|
|
}
|
|
|
|
export const authRouter = createTRPCRouter({
|
|
/**
|
|
* Request a password reset email.
|
|
* Always returns { success: true } — even if the email is not registered —
|
|
* to prevent user enumeration.
|
|
*/
|
|
requestPasswordReset: publicProcedure
|
|
.input(z.object({ email: z.string().email() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
|
|
const keys = ipKey
|
|
? [`email:${input.email.toLowerCase()}`, ipKey]
|
|
: [`email:${input.email.toLowerCase()}`];
|
|
const rl = await authRateLimiter(keys);
|
|
if (!rl.allowed) {
|
|
throw new TRPCError({
|
|
code: "TOO_MANY_REQUESTS",
|
|
message: "Too many password reset attempts. Please wait before trying again.",
|
|
});
|
|
}
|
|
|
|
const user = await ctx.db.user.findUnique({
|
|
where: { email: input.email },
|
|
select: { id: true, email: true },
|
|
});
|
|
|
|
if (!user) {
|
|
// Timing-safe: don't reveal whether the email exists
|
|
return { success: true };
|
|
}
|
|
|
|
// Delete any existing (unused) reset tokens for this email
|
|
await ctx.db.passwordResetToken.deleteMany({
|
|
where: { email: input.email, usedAt: null },
|
|
});
|
|
|
|
const token = randomBytes(32).toString("hex");
|
|
const expiresAt = new Date(Date.now() + RESET_TTL_MS);
|
|
|
|
await ctx.db.passwordResetToken.create({
|
|
data: { email: input.email, token, expiresAt },
|
|
});
|
|
|
|
const resetUrl = `${getAppBaseUrl()}/auth/reset-password/${token}`;
|
|
|
|
void sendEmail({
|
|
to: input.email,
|
|
subject: "CapaKraken — reset your password",
|
|
text: `You requested a password reset.\n\nReset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you did not request this, ignore this email.`,
|
|
html: resetEmailHtml(resetUrl),
|
|
});
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
/** Validate a reset token and set a new password. */
|
|
resetPassword: publicProcedure
|
|
.input(
|
|
z.object({
|
|
token: z.string().min(1),
|
|
password: z
|
|
.string()
|
|
.min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE)
|
|
.max(PASSWORD_MAX_LENGTH),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Rate-limit keyed on IP (token is always new so token-keying is a no-op).
|
|
// We cannot key on the resolved email before the token lookup; fall back
|
|
// to IP-only here and apply an email-keyed limit AFTER the successful
|
|
// lookup to bound per-email brute-force.
|
|
const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
|
|
if (ipKey) {
|
|
const rl = await authRateLimiter(ipKey);
|
|
if (!rl.allowed) {
|
|
throw new TRPCError({
|
|
code: "TOO_MANY_REQUESTS",
|
|
message: "Too many password reset attempts. Please wait before trying again.",
|
|
});
|
|
}
|
|
}
|
|
|
|
const record = await ctx.db.passwordResetToken.findUnique({
|
|
where: { token: input.token },
|
|
});
|
|
|
|
if (!record) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Reset link not found." });
|
|
}
|
|
if (record.usedAt) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "This reset link has already been used.",
|
|
});
|
|
}
|
|
if (record.expiresAt < new Date()) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired." });
|
|
}
|
|
|
|
// Second-layer limit keyed on the resolved email, so a targeted
|
|
// attacker cannot exhaust reset attempts for a known user even if
|
|
// they cycle source IPs.
|
|
const emailRl = await authRateLimiter(`email-reset:${record.email.toLowerCase()}`);
|
|
if (!emailRl.allowed) {
|
|
throw new TRPCError({
|
|
code: "TOO_MANY_REQUESTS",
|
|
message: "Too many password reset attempts. Please wait before trying again.",
|
|
});
|
|
}
|
|
|
|
// Reject weak/common/identity-related passwords *after* the token is
|
|
// validated so attackers can't probe the policy without a valid link.
|
|
const userForPolicy = await ctx.db.user.findUnique({
|
|
where: { email: record.email },
|
|
select: { email: true, name: true },
|
|
});
|
|
const policy = checkPasswordPolicy(input.password, userForPolicy ?? undefined);
|
|
if (!policy.ok) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: policy.reason });
|
|
}
|
|
|
|
const { hash } = await import("@node-rs/argon2");
|
|
const passwordHash = await hash(input.password);
|
|
|
|
// All three operations must succeed atomically: if session deletion
|
|
// fails after the password is already changed, old sessions could
|
|
// persist with the new password (CWE-613).
|
|
await ctx.db.$transaction(async (tx) => {
|
|
const updatedUser = await tx.user.update({
|
|
where: { email: record.email },
|
|
data: { passwordHash },
|
|
select: { id: true },
|
|
});
|
|
|
|
await tx.activeSession.deleteMany({ where: { userId: updatedUser.id } });
|
|
|
|
await tx.passwordResetToken.update({
|
|
where: { token: input.token },
|
|
data: { usedAt: new Date() },
|
|
});
|
|
});
|
|
|
|
return { success: true };
|
|
}),
|
|
});
|