You can ignore this if you did not request this email, someone may have requested it in error
@@ -1212,9 +1256,7 @@ export const authRouter = createTRPCRouter({
if (
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
) {
- const { ipAddress, userAgent } = getAuditContext(
- getH3Event(ctx)
- );
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.password.reset.request",
eventData: {
@@ -1265,22 +1307,24 @@ export const authRouter = createTRPCRouter({
}
try {
- const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
- const { payload } = await jwtVerify(token, secret);
+ // Validate and consume the password reset token
+ const tokenValidation = await validatePasswordResetToken(token);
- if (!payload.id || typeof payload.id !== "string") {
+ if (!tokenValidation) {
throw new TRPCError({
code: "UNAUTHORIZED",
- message: "bad token"
+ message: "Invalid or expired reset token"
});
}
+ const { userId, tokenId } = tokenValidation;
+
const conn = ConnectionFactory();
const passwordHash = await hashPassword(newPassword);
const userRes = await conn.execute({
sql: "SELECT provider FROM User WHERE id = ?",
- args: [payload.id]
+ args: [userId]
});
if (userRes.rows.length === 0) {
@@ -1297,16 +1341,20 @@ export const authRouter = createTRPCRouter({
!["google", "github", "apple"].includes(currentProvider)
) {
await conn.execute({
- sql: "UPDATE User SET password_hash = ?, provider = ? WHERE id = ?",
- args: [passwordHash, "email", payload.id]
+ sql: "UPDATE User SET password_hash = ?, provider = ?, failed_attempts = 0, locked_until = NULL WHERE id = ?",
+ args: [passwordHash, "email", userId]
});
} else {
await conn.execute({
- sql: "UPDATE User SET password_hash = ? WHERE id = ?",
- args: [passwordHash, payload.id]
+ sql: "UPDATE User SET password_hash = ?, failed_attempts = 0, locked_until = NULL WHERE id = ?",
+ args: [passwordHash, userId]
});
}
+ // Mark token as used
+ await markPasswordResetTokenUsed(tokenId);
+
+ // Clear authentication cookies
setCookie(getH3Event(ctx), "emailToken", "", {
maxAge: 0,
path: "/"
@@ -1319,7 +1367,7 @@ export const authRouter = createTRPCRouter({
// Log successful password reset
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
- userId: payload.id,
+ userId: userId,
eventType: "auth.password.reset.complete",
eventData: {},
ipAddress,
@@ -1347,7 +1395,7 @@ export const authRouter = createTRPCRouter({
console.error("Password reset error:", error);
throw new TRPCError({
code: "UNAUTHORIZED",
- message: "token expired"
+ message: "Invalid or expired reset token"
});
}
}),
@@ -1466,9 +1514,7 @@ export const authRouter = createTRPCRouter({
if (
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
) {
- const { ipAddress, userAgent } = getAuditContext(
- getH3Event(ctx)
- );
+ const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.email.verify.request",
eventData: {
diff --git a/src/server/security.ts b/src/server/security.ts
index c25f7d6..eeb4a39 100644
--- a/src/server/security.ts
+++ b/src/server/security.ts
@@ -400,3 +400,251 @@ export function rateLimitEmailVerification(
event
);
}
+
+// ========== Account Lockout ==========
+
+/**
+ * Account lockout configuration
+ */
+export const ACCOUNT_LOCKOUT = {
+ MAX_FAILED_ATTEMPTS: 5,
+ LOCKOUT_DURATION_MS: 5 * 60 * 1000 // 5 minutes
+} as const;
+
+/**
+ * Check if an account is locked
+ * @param userId - User ID to check
+ * @returns Object with isLocked status and remaining time if locked
+ */
+export async function checkAccountLockout(userId: string): Promise<{
+ isLocked: boolean;
+ remainingMs?: number;
+ lockedUntil?: string;
+}> {
+ const { ConnectionFactory } = await import("./database");
+ const conn = ConnectionFactory();
+
+ const result = await conn.execute({
+ sql: "SELECT locked_until, failed_attempts FROM User WHERE id = ?",
+ args: [userId]
+ });
+
+ if (result.rows.length === 0) {
+ return { isLocked: false };
+ }
+
+ const user = result.rows[0];
+ const lockedUntil = user.locked_until as string | null;
+
+ if (!lockedUntil) {
+ return { isLocked: false };
+ }
+
+ const lockExpiry = new Date(lockedUntil);
+ const now = new Date();
+
+ if (lockExpiry > now) {
+ const remainingMs = lockExpiry.getTime() - now.getTime();
+ return {
+ isLocked: true,
+ remainingMs,
+ lockedUntil
+ };
+ }
+
+ // Lockout expired, clear it
+ await conn.execute({
+ sql: "UPDATE User SET locked_until = NULL, failed_attempts = 0 WHERE id = ?",
+ args: [userId]
+ });
+
+ return { isLocked: false };
+}
+
+/**
+ * Record a failed login attempt and lock account if threshold exceeded
+ * @param userId - User ID
+ * @returns Object with isLocked status and remaining time if locked
+ */
+export async function recordFailedLogin(userId: string): Promise<{
+ isLocked: boolean;
+ remainingMs?: number;
+ failedAttempts: number;
+}> {
+ const { ConnectionFactory } = await import("./database");
+ const conn = ConnectionFactory();
+
+ // Increment failed attempts
+ const result = await conn.execute({
+ sql: `UPDATE User
+ SET failed_attempts = COALESCE(failed_attempts, 0) + 1
+ WHERE id = ?
+ RETURNING failed_attempts`,
+ args: [userId]
+ });
+
+ const failedAttempts = (result.rows[0]?.failed_attempts as number) || 0;
+
+ // Check if we should lock the account
+ if (failedAttempts >= ACCOUNT_LOCKOUT.MAX_FAILED_ATTEMPTS) {
+ const lockedUntil = new Date(
+ Date.now() + ACCOUNT_LOCKOUT.LOCKOUT_DURATION_MS
+ );
+
+ await conn.execute({
+ sql: "UPDATE User SET locked_until = ? WHERE id = ?",
+ args: [lockedUntil.toISOString(), userId]
+ });
+
+ return {
+ isLocked: true,
+ remainingMs: ACCOUNT_LOCKOUT.LOCKOUT_DURATION_MS,
+ failedAttempts
+ };
+ }
+
+ return {
+ isLocked: false,
+ failedAttempts
+ };
+}
+
+/**
+ * Reset failed login attempts on successful login
+ * @param userId - User ID
+ */
+export async function resetFailedAttempts(userId: string): Promise