lockout
This commit is contained in:
@@ -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
|
||||
(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
error().includes("Account locked") ||
|
||||
error().includes("Account is locked")
|
||||
}
|
||||
>
|
||||
<div class="mb-2 text-base font-semibold">
|
||||
🔒 Account Locked
|
||||
</div>
|
||||
<div class="text-sm">{error()}</div>
|
||||
</Show>
|
||||
<Show when={error().includes("Too many attempts")}>
|
||||
<div class="mb-2 text-base font-semibold">
|
||||
⏱️ Rate Limit Exceeded
|
||||
</div>
|
||||
<div class="text-crust text-sm">{error()}</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
error() &&
|
||||
error() !== "passwordMismatch" &&
|
||||
error() !== "duplicate"
|
||||
error() !== "duplicate" &&
|
||||
!error().includes("Account locked") &&
|
||||
!error().includes("Account is locked") &&
|
||||
!error().includes("Too many attempts")
|
||||
}
|
||||
>
|
||||
<div class="text-base text-sm">{error()}</div>
|
||||
|
||||
@@ -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 */}
|
||||
<Show when={error()}>
|
||||
<div class="mt-4 flex justify-center">
|
||||
<div class="text-red text-sm italic">{error()}</div>
|
||||
<div
|
||||
class={`${
|
||||
error().includes("Too many attempts") ||
|
||||
error().includes("wait before requesting")
|
||||
? "border-maroon bg-red rounded-lg border px-4 py-3"
|
||||
: ""
|
||||
} max-w-md text-center`}
|
||||
>
|
||||
<Show when={error().includes("Too many attempts")}>
|
||||
<div class="mb-1 text-base font-semibold">
|
||||
⏱️ Rate Limit Exceeded
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class={`${
|
||||
error().includes("Too many attempts") ||
|
||||
error().includes("wait before requesting")
|
||||
? "text-sm"
|
||||
: "text-red text-sm italic"
|
||||
}`}
|
||||
>
|
||||
{error()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -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,7 +1005,10 @@ 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) {
|
||||
@@ -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 = `<html>
|
||||
<head>
|
||||
@@ -1176,6 +1217,9 @@ export const authRouter = createTRPCRouter({
|
||||
<div class="center">
|
||||
<a href="${domain}/login/password-reset?token=${token}" class="button">Reset Password</a>
|
||||
</div>
|
||||
<div class="center">
|
||||
<p>This link will expire in 1 hour and can only be used once.</p>
|
||||
</div>
|
||||
<div class="center">
|
||||
<p>You can ignore this if you did not request this email, someone may have requested it in error</p>
|
||||
</div>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user