diff --git a/src/db/create.ts b/src/db/create.ts index 69fe0a1..9d56c74 100644 --- a/src/db/create.ts +++ b/src/db/create.ts @@ -9,9 +9,43 @@ export const model: { [key: string]: string } = { display_name TEXT, provider TEXT, image TEXT, - registered_at TEXT NOT NULL DEFAULT (datetime('now')) + registered_at TEXT NOT NULL DEFAULT (datetime('now')), + failed_attempts INTEGER DEFAULT 0, + locked_until TEXT ); `, + Session: ` + CREATE TABLE Session + ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token_family TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + last_used TEXT NOT NULL DEFAULT (datetime('now')), + ip_address TEXT, + user_agent TEXT, + revoked INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_user_id ON Session (user_id); + CREATE INDEX IF NOT EXISTS idx_session_expires_at ON Session (expires_at); + `, + PasswordResetToken: ` + CREATE TABLE PasswordResetToken + ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_password_reset_token ON PasswordResetToken (token); + CREATE INDEX IF NOT EXISTS idx_password_reset_user_id ON PasswordResetToken (user_id); + CREATE INDEX IF NOT EXISTS idx_password_reset_expires_at ON PasswordResetToken (expires_at); + `, Post: ` CREATE TABLE Post ( diff --git a/src/db/types.ts b/src/db/types.ts index e5c13a2..4192181 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -13,6 +13,29 @@ export interface User { db_destroy_date?: string | null; created_at: string; updated_at: string; + failed_attempts?: number; + locked_until?: string | null; +} + +export interface Session { + id: string; + user_id: string; + token_family: string; + created_at: string; + expires_at: string; + last_used: string; + ip_address?: string | null; + user_agent?: string | null; + revoked: number; +} + +export interface PasswordResetToken { + id: string; + token: string; + user_id: string; + expires_at: string; + used_at?: string | null; + created_at: string; } export interface Post { diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index 1e8ddc9..c42d985 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -175,7 +175,17 @@ export default function LoginPage() { result.error?.message || result.result?.data?.message || "Registration failed"; + const errorCode = result.error?.data?.code; + + // Check for rate limiting if ( + errorCode === "TOO_MANY_REQUESTS" || + errorMsg.includes("Too many attempts") + ) { + setError(errorMsg); + } + // Check for duplicate email + else if ( errorMsg.includes("duplicate") || errorMsg.includes("already exists") ) { @@ -210,7 +220,29 @@ export default function LoginPage() { navigate("/account", { replace: true }); }, 500); } else { - setShowPasswordError(true); + // Handle specific error types + const errorMessage = result.error?.message || ""; + const errorCode = result.error?.data?.code; + + // Check for rate limiting + if ( + errorCode === "TOO_MANY_REQUESTS" || + errorMessage.includes("Too many attempts") + ) { + setError(errorMessage); + } + // Check for account lockout + else if ( + errorCode === "FORBIDDEN" || + errorMessage.includes("Account locked") || + errorMessage.includes("Account is locked") + ) { + setError(errorMessage); + } + // Generic login failure + else { + setShowPasswordError(true); + } } } else { // Email link login flow @@ -254,7 +286,22 @@ export default function LoginPage() { result.error?.message || result.result?.data?.message || "Failed to send email"; - setError(errorMsg); + const errorCode = result.error?.data?.code; + + // Check for rate limiting or countdown not expired + if ( + errorCode === "TOO_MANY_REQUESTS" || + errorMsg.includes("countdown not expired") || + errorMsg.includes("Too many attempts") + ) { + setError( + errorMsg.includes("countdown") + ? "Please wait before requesting another email link" + : errorMsg + ); + } else { + setError(errorMsg); + } } } } catch (err: any) { @@ -339,11 +386,31 @@ export default function LoginPage() { Email Already Exists! + +
+ 🔒 Account Locked +
+
{error()}
+
+ +
+ ⏱️ Rate Limit Exceeded +
+
{error()}
+
{error()}
diff --git a/src/routes/login/request-password-reset.tsx b/src/routes/login/request-password-reset.tsx index 2078bd5..885dfac 100644 --- a/src/routes/login/request-password-reset.tsx +++ b/src/routes/login/request-password-reset.tsx @@ -92,7 +92,17 @@ export default function RequestPasswordResetPage() { } } else { const errorMsg = result.error?.message || "Failed to send reset email"; - if (errorMsg.includes("countdown not expired")) { + const errorCode = result.error?.data?.code; + + // Handle rate limiting + if ( + errorCode === "TOO_MANY_REQUESTS" || + errorMsg.includes("Too many attempts") + ) { + setError(errorMsg); + } + // Handle countdown not expired + else if (errorMsg.includes("countdown not expired")) { setError("Please wait before requesting another reset email"); } else { setError(errorMsg); @@ -192,7 +202,30 @@ export default function RequestPasswordResetPage() { {/* Error Message */}
-
{error()}
+
+ +
+ ⏱️ Rate Limit Exceeded +
+
+
+ {error()} +
+
diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index 8960e20..f4f583e 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -35,7 +35,13 @@ import { rateLimitLogin, rateLimitPasswordReset, rateLimitRegistration, - rateLimitEmailVerification + rateLimitEmailVerification, + checkAccountLockout, + recordFailedLogin, + resetFailedAttempts, + createPasswordResetToken, + validatePasswordResetToken, + markPasswordResetTokenUsed } from "~/server/security"; import { logAuditEvent } from "~/server/audit"; import type { H3Event } from "vinxi/http"; @@ -48,7 +54,7 @@ import type { Context } from "../utils"; */ function getH3Event(ctx: Context): H3Event { // Check if nativeEvent exists (production) - if (ctx.event && 'nativeEvent' in ctx.event && ctx.event.nativeEvent) { + if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) { return ctx.event.nativeEvent as H3Event; } // Otherwise, assume ctx.event is H3Event (development) @@ -133,7 +139,7 @@ function setAuthCookies( path: "/", httpOnly: true, secure: env.NODE_ENV === "production", - sameSite: "lax", + sameSite: "strict", ...options }; @@ -310,8 +316,7 @@ export const authRouter = createTRPCRouter({ // Create session with client info const clientIP = getClientIP(getH3Event(ctx)); - const userAgent = - getUserAgent(getH3Event(ctx)); + const userAgent = getUserAgent(getH3Event(ctx)); const sessionId = await createSession( userId, "14d", @@ -326,7 +331,7 @@ export const authRouter = createTRPCRouter({ path: "/", httpOnly: true, secure: env.NODE_ENV === "production", - sameSite: "lax" + sameSite: "strict" }); // Set CSRF token for authenticated session @@ -493,8 +498,7 @@ export const authRouter = createTRPCRouter({ // Create session with client info const clientIP = getClientIP(getH3Event(ctx)); - const userAgent = - getUserAgent(getH3Event(ctx)); + const userAgent = getUserAgent(getH3Event(ctx)); const sessionId = await createSession( userId, "14d", @@ -509,7 +513,7 @@ export const authRouter = createTRPCRouter({ path: "/", httpOnly: true, secure: env.NODE_ENV === "production", - sameSite: "lax" + sameSite: "strict" }); // Set CSRF token for authenticated session @@ -613,8 +617,7 @@ export const authRouter = createTRPCRouter({ // Create session with client info const clientIP = getClientIP(getH3Event(ctx)); - const userAgent = - getUserAgent(getH3Event(ctx)); + const userAgent = getUserAgent(getH3Event(ctx)); const expiresIn = rememberMe ? "14d" : "12h"; const sessionId = await createSession( userId, @@ -629,19 +632,14 @@ export const authRouter = createTRPCRouter({ path: "/", httpOnly: true, secure: env.NODE_ENV === "production", - sameSite: "lax" + sameSite: "strict" }; if (rememberMe) { cookieOptions.maxAge = 60 * 60 * 24 * 14; } - setCookie( - getH3Event(ctx), - "userIDToken", - userToken, - cookieOptions - ); + setCookie(getH3Event(ctx), "userIDToken", userToken, cookieOptions); // Set CSRF token for authenticated session setCSRFToken(getH3Event(ctx)); @@ -789,8 +787,7 @@ export const authRouter = createTRPCRouter({ // Create session with client info const clientIP = getClientIP(getH3Event(ctx)); - const userAgent = - getUserAgent(getH3Event(ctx)); + const userAgent = getUserAgent(getH3Event(ctx)); const sessionId = await createSession( userId, "14d", @@ -805,7 +802,7 @@ export const authRouter = createTRPCRouter({ path: "/", httpOnly: true, secure: env.NODE_ENV === "production", - sameSite: "lax" + sameSite: "strict" }); // Set CSRF token for authenticated session @@ -869,23 +866,57 @@ export const authRouter = createTRPCRouter({ // Check all conditions after password verification if (!user || !passwordHash || !passwordMatch) { - // Debug logging (remove after fixing) - console.log("Login failed for:", email); - console.log("User found:", !!user); - console.log("Password hash exists:", !!passwordHash); - console.log("Password match:", passwordMatch); + // Record failed login attempt if user exists + if (user?.id) { + const lockoutStatus = await recordFailedLogin(user.id); - // Log failed login attempt (wrap in try-catch to ensure it never blocks auth flow) + if (lockoutStatus.isLocked) { + const remainingSec = Math.ceil( + (lockoutStatus.remainingMs || 0) / 1000 + ); + + // Log account lockout + try { + const { ipAddress, userAgent } = getAuditContext( + getH3Event(ctx) + ); + await logAuditEvent({ + userId: user.id, + eventType: "auth.login.failed", + eventData: { + email, + method: "password", + reason: "account_locked", + failedAttempts: lockoutStatus.failedAttempts + }, + ipAddress, + userAgent, + success: false + }); + } catch (auditError) { + console.error("Audit logging failed:", auditError); + } + + throw new TRPCError({ + code: "FORBIDDEN", + message: `Account locked due to too many failed login attempts. Try again in ${Math.ceil(remainingSec / 60)} minutes.` + }); + } + } + + // Log failed login attempt try { - const { ipAddress, userAgent } = getAuditContext( - getH3Event(ctx) - ); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ + userId: user?.id, eventType: "auth.login.failed", eventData: { email, method: "password", - reason: "invalid_credentials" + reason: "invalid_credentials", + failedAttempts: user?.id + ? (res.rows[0]?.failed_attempts as number) + : undefined }, ipAddress, userAgent, @@ -901,6 +932,19 @@ export const authRouter = createTRPCRouter({ }); } + // Check if account is locked before allowing login + const lockoutCheck = await checkAccountLockout(user.id); + if (lockoutCheck.isLocked) { + const remainingSec = Math.ceil( + (lockoutCheck.remainingMs || 0) / 1000 + ); + + throw new TRPCError({ + code: "FORBIDDEN", + message: `Account is locked due to too many failed login attempts. Try again in ${Math.ceil(remainingSec / 60)} minutes.` + }); + } + if ( !user.provider || !["email", "google", "github", "apple"].includes(user.provider) @@ -911,11 +955,13 @@ export const authRouter = createTRPCRouter({ }); } + // Reset failed attempts on successful login + await resetFailedAttempts(user.id); + const expiresIn = rememberMe ? "14d" : "12h"; // Create session with client info (reuse clientIP from rate limiting) - const userAgent = - getUserAgent(getH3Event(ctx)); + const userAgent = getUserAgent(getH3Event(ctx)); const sessionId = await createSession( user.id, expiresIn, @@ -929,7 +975,7 @@ export const authRouter = createTRPCRouter({ path: "/", httpOnly: true, secure: env.NODE_ENV === "production", - sameSite: "lax" + sameSite: "strict" }; if (rememberMe) { @@ -959,13 +1005,16 @@ export const authRouter = createTRPCRouter({ } catch (error) { // Log the actual error for debugging console.error("emailPasswordLogin error:", error); - console.error("Error stack:", error instanceof Error ? error.stack : "no stack"); - + console.error( + "Error stack:", + error instanceof Error ? error.stack : "no stack" + ); + // Re-throw TRPCErrors as-is if (error instanceof TRPCError) { throw error; } - + // Wrap other errors throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", @@ -986,10 +1035,7 @@ export const authRouter = createTRPCRouter({ const { email, rememberMe } = input; try { - const requested = getCookie( - getH3Event(ctx), - "emailLoginLinkRequested" - ); + const requested = getCookie(getH3Event(ctx), "emailLoginLinkRequested"); if (requested) { const expires = new Date(requested); const remaining = expires.getTime() - Date.now(); @@ -1111,10 +1157,7 @@ export const authRouter = createTRPCRouter({ rateLimitPasswordReset(clientIP, getH3Event(ctx)); try { - const requested = getCookie( - getH3Event(ctx), - "passwordResetRequested" - ); + const requested = getCookie(getH3Event(ctx), "passwordResetRequested"); if (requested) { const expires = new Date(requested); const remaining = expires.getTime() - Date.now(); @@ -1138,11 +1181,9 @@ export const authRouter = createTRPCRouter({ const user = res.rows[0] as unknown as User; - const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); - const token = await new SignJWT({ id: user.id }) - .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("15m") - .sign(secret); + // Create password reset token (1 hour expiry, single-use) + const { token } = await createPasswordResetToken(user.id); + const domain = env.VITE_DOMAIN || "https://freno.me"; const htmlContent = ` @@ -1176,6 +1217,9 @@ export const authRouter = createTRPCRouter({
Reset Password
+
+

This link will expire in 1 hour and can only be used once.

+

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 { + const { ConnectionFactory } = await import("./database"); + const conn = ConnectionFactory(); + + await conn.execute({ + sql: "UPDATE User SET failed_attempts = 0, locked_until = NULL WHERE id = ?", + args: [userId] + }); +} + +// ========== Password Reset Token Management ========== + +/** + * Password reset token configuration + */ +export const PASSWORD_RESET_CONFIG = { + TOKEN_EXPIRY_MS: 60 * 60 * 1000 // 1 hour +} as const; + +/** + * Create a password reset token + * @param userId - User ID + * @returns The reset token and token ID + */ +export async function createPasswordResetToken(userId: string): Promise<{ + token: string; + tokenId: string; + expiresAt: string; +}> { + const { ConnectionFactory } = await import("./database"); + const { v4: uuid } = await import("uuid"); + const conn = ConnectionFactory(); + + // Generate cryptographically secure token + const token = crypto.randomUUID(); + const tokenId = uuid(); + const expiresAt = new Date( + Date.now() + PASSWORD_RESET_CONFIG.TOKEN_EXPIRY_MS + ); + + // Invalidate any existing unused tokens for this user + await conn.execute({ + sql: "UPDATE PasswordResetToken SET used_at = datetime('now') WHERE user_id = ? AND used_at IS NULL", + args: [userId] + }); + + // Create new token + await conn.execute({ + sql: `INSERT INTO PasswordResetToken (id, token, user_id, expires_at) + VALUES (?, ?, ?, ?)`, + args: [tokenId, token, userId, expiresAt.toISOString()] + }); + + return { + token, + tokenId, + expiresAt: expiresAt.toISOString() + }; +} + +/** + * Validate and consume a password reset token + * @param token - Reset token + * @returns User ID if valid, null otherwise + */ +export async function validatePasswordResetToken( + token: string +): Promise<{ userId: string; tokenId: string } | null> { + const { ConnectionFactory } = await import("./database"); + const conn = ConnectionFactory(); + + const result = await conn.execute({ + sql: `SELECT id, user_id, expires_at, used_at + FROM PasswordResetToken + WHERE token = ?`, + args: [token] + }); + + if (result.rows.length === 0) { + return null; + } + + const tokenRecord = result.rows[0]; + + // Check if already used + if (tokenRecord.used_at) { + return null; + } + + // Check if expired + const expiresAt = new Date(tokenRecord.expires_at as string); + if (expiresAt < new Date()) { + return null; + } + + return { + userId: tokenRecord.user_id as string, + tokenId: tokenRecord.id as string + }; +} + +/** + * Mark a password reset token as used + * @param tokenId - Token ID + */ +export async function markPasswordResetTokenUsed( + tokenId: string +): Promise { + const { ConnectionFactory } = await import("./database"); + const conn = ConnectionFactory(); + + await conn.execute({ + sql: "UPDATE PasswordResetToken SET used_at = datetime('now') WHERE id = ?", + args: [tokenId] + }); +} + +/** + * Clean up expired password reset tokens + * Should be run periodically (e.g., via cron job) + */ +export async function cleanupExpiredPasswordResetTokens(): Promise { + const { ConnectionFactory } = await import("./database"); + const conn = ConnectionFactory(); + + const result = await conn.execute({ + sql: `DELETE FROM PasswordResetToken + WHERE expires_at < datetime('now') + OR used_at IS NOT NULL + RETURNING id`, + args: [] + }); + + return result.rows.length; +}