lockout
This commit is contained in:
@@ -9,9 +9,43 @@ export const model: { [key: string]: string } = {
|
|||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
provider TEXT,
|
provider TEXT,
|
||||||
image 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: `
|
Post: `
|
||||||
CREATE TABLE Post
|
CREATE TABLE Post
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -13,6 +13,29 @@ export interface User {
|
|||||||
db_destroy_date?: string | null;
|
db_destroy_date?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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 {
|
export interface Post {
|
||||||
|
|||||||
@@ -175,7 +175,17 @@ export default function LoginPage() {
|
|||||||
result.error?.message ||
|
result.error?.message ||
|
||||||
result.result?.data?.message ||
|
result.result?.data?.message ||
|
||||||
"Registration failed";
|
"Registration failed";
|
||||||
|
const errorCode = result.error?.data?.code;
|
||||||
|
|
||||||
|
// Check for rate limiting
|
||||||
if (
|
if (
|
||||||
|
errorCode === "TOO_MANY_REQUESTS" ||
|
||||||
|
errorMsg.includes("Too many attempts")
|
||||||
|
) {
|
||||||
|
setError(errorMsg);
|
||||||
|
}
|
||||||
|
// Check for duplicate email
|
||||||
|
else if (
|
||||||
errorMsg.includes("duplicate") ||
|
errorMsg.includes("duplicate") ||
|
||||||
errorMsg.includes("already exists")
|
errorMsg.includes("already exists")
|
||||||
) {
|
) {
|
||||||
@@ -210,8 +220,30 @@ export default function LoginPage() {
|
|||||||
navigate("/account", { replace: true });
|
navigate("/account", { replace: true });
|
||||||
}, 500);
|
}, 500);
|
||||||
} else {
|
} else {
|
||||||
|
// 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);
|
setShowPasswordError(true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Email link login flow
|
// Email link login flow
|
||||||
if (!emailRef || !rememberMeRef) {
|
if (!emailRef || !rememberMeRef) {
|
||||||
@@ -254,9 +286,24 @@ export default function LoginPage() {
|
|||||||
result.error?.message ||
|
result.error?.message ||
|
||||||
result.result?.data?.message ||
|
result.result?.data?.message ||
|
||||||
"Failed to send email";
|
"Failed to send email";
|
||||||
|
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);
|
setError(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Login error:", err);
|
console.error("Login error:", err);
|
||||||
setError(err.message || "An error occurred");
|
setError(err.message || "An error occurred");
|
||||||
@@ -339,11 +386,31 @@ export default function LoginPage() {
|
|||||||
Email Already Exists!
|
Email Already Exists!
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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
|
<Show
|
||||||
when={
|
when={
|
||||||
error() &&
|
error() &&
|
||||||
error() !== "passwordMismatch" &&
|
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>
|
<div class="text-base text-sm">{error()}</div>
|
||||||
|
|||||||
@@ -92,7 +92,17 @@ export default function RequestPasswordResetPage() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorMsg = result.error?.message || "Failed to send reset email";
|
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");
|
setError("Please wait before requesting another reset email");
|
||||||
} else {
|
} else {
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
@@ -192,7 +202,30 @@ export default function RequestPasswordResetPage() {
|
|||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<div class="mt-4 flex justify-center">
|
<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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ import {
|
|||||||
rateLimitLogin,
|
rateLimitLogin,
|
||||||
rateLimitPasswordReset,
|
rateLimitPasswordReset,
|
||||||
rateLimitRegistration,
|
rateLimitRegistration,
|
||||||
rateLimitEmailVerification
|
rateLimitEmailVerification,
|
||||||
|
checkAccountLockout,
|
||||||
|
recordFailedLogin,
|
||||||
|
resetFailedAttempts,
|
||||||
|
createPasswordResetToken,
|
||||||
|
validatePasswordResetToken,
|
||||||
|
markPasswordResetTokenUsed
|
||||||
} from "~/server/security";
|
} from "~/server/security";
|
||||||
import { logAuditEvent } from "~/server/audit";
|
import { logAuditEvent } from "~/server/audit";
|
||||||
import type { H3Event } from "vinxi/http";
|
import type { H3Event } from "vinxi/http";
|
||||||
@@ -48,7 +54,7 @@ import type { Context } from "../utils";
|
|||||||
*/
|
*/
|
||||||
function getH3Event(ctx: Context): H3Event {
|
function getH3Event(ctx: Context): H3Event {
|
||||||
// Check if nativeEvent exists (production)
|
// 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;
|
return ctx.event.nativeEvent as H3Event;
|
||||||
}
|
}
|
||||||
// Otherwise, assume ctx.event is H3Event (development)
|
// Otherwise, assume ctx.event is H3Event (development)
|
||||||
@@ -133,7 +139,7 @@ function setAuthCookies(
|
|||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: env.NODE_ENV === "production",
|
secure: env.NODE_ENV === "production",
|
||||||
sameSite: "lax",
|
sameSite: "strict",
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -310,8 +316,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Create session with client info
|
// Create session with client info
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const clientIP = getClientIP(getH3Event(ctx));
|
||||||
const userAgent =
|
const userAgent = getUserAgent(getH3Event(ctx));
|
||||||
getUserAgent(getH3Event(ctx));
|
|
||||||
const sessionId = await createSession(
|
const sessionId = await createSession(
|
||||||
userId,
|
userId,
|
||||||
"14d",
|
"14d",
|
||||||
@@ -326,7 +331,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: env.NODE_ENV === "production",
|
secure: env.NODE_ENV === "production",
|
||||||
sameSite: "lax"
|
sameSite: "strict"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set CSRF token for authenticated session
|
// Set CSRF token for authenticated session
|
||||||
@@ -493,8 +498,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Create session with client info
|
// Create session with client info
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const clientIP = getClientIP(getH3Event(ctx));
|
||||||
const userAgent =
|
const userAgent = getUserAgent(getH3Event(ctx));
|
||||||
getUserAgent(getH3Event(ctx));
|
|
||||||
const sessionId = await createSession(
|
const sessionId = await createSession(
|
||||||
userId,
|
userId,
|
||||||
"14d",
|
"14d",
|
||||||
@@ -509,7 +513,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: env.NODE_ENV === "production",
|
secure: env.NODE_ENV === "production",
|
||||||
sameSite: "lax"
|
sameSite: "strict"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set CSRF token for authenticated session
|
// Set CSRF token for authenticated session
|
||||||
@@ -613,8 +617,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Create session with client info
|
// Create session with client info
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const clientIP = getClientIP(getH3Event(ctx));
|
||||||
const userAgent =
|
const userAgent = getUserAgent(getH3Event(ctx));
|
||||||
getUserAgent(getH3Event(ctx));
|
|
||||||
const expiresIn = rememberMe ? "14d" : "12h";
|
const expiresIn = rememberMe ? "14d" : "12h";
|
||||||
const sessionId = await createSession(
|
const sessionId = await createSession(
|
||||||
userId,
|
userId,
|
||||||
@@ -629,19 +632,14 @@ export const authRouter = createTRPCRouter({
|
|||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: env.NODE_ENV === "production",
|
secure: env.NODE_ENV === "production",
|
||||||
sameSite: "lax"
|
sameSite: "strict"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (rememberMe) {
|
if (rememberMe) {
|
||||||
cookieOptions.maxAge = 60 * 60 * 24 * 14;
|
cookieOptions.maxAge = 60 * 60 * 24 * 14;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCookie(
|
setCookie(getH3Event(ctx), "userIDToken", userToken, cookieOptions);
|
||||||
getH3Event(ctx),
|
|
||||||
"userIDToken",
|
|
||||||
userToken,
|
|
||||||
cookieOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set CSRF token for authenticated session
|
// Set CSRF token for authenticated session
|
||||||
setCSRFToken(getH3Event(ctx));
|
setCSRFToken(getH3Event(ctx));
|
||||||
@@ -789,8 +787,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Create session with client info
|
// Create session with client info
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const clientIP = getClientIP(getH3Event(ctx));
|
||||||
const userAgent =
|
const userAgent = getUserAgent(getH3Event(ctx));
|
||||||
getUserAgent(getH3Event(ctx));
|
|
||||||
const sessionId = await createSession(
|
const sessionId = await createSession(
|
||||||
userId,
|
userId,
|
||||||
"14d",
|
"14d",
|
||||||
@@ -805,7 +802,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: env.NODE_ENV === "production",
|
secure: env.NODE_ENV === "production",
|
||||||
sameSite: "lax"
|
sameSite: "strict"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set CSRF token for authenticated session
|
// Set CSRF token for authenticated session
|
||||||
@@ -869,23 +866,57 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Check all conditions after password verification
|
// Check all conditions after password verification
|
||||||
if (!user || !passwordHash || !passwordMatch) {
|
if (!user || !passwordHash || !passwordMatch) {
|
||||||
// Debug logging (remove after fixing)
|
// Record failed login attempt if user exists
|
||||||
console.log("Login failed for:", email);
|
if (user?.id) {
|
||||||
console.log("User found:", !!user);
|
const lockoutStatus = await recordFailedLogin(user.id);
|
||||||
console.log("Password hash exists:", !!passwordHash);
|
|
||||||
console.log("Password match:", passwordMatch);
|
|
||||||
|
|
||||||
// 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 {
|
try {
|
||||||
const { ipAddress, userAgent } = getAuditContext(
|
const { ipAddress, userAgent } = getAuditContext(
|
||||||
getH3Event(ctx)
|
getH3Event(ctx)
|
||||||
);
|
);
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
|
userId: user.id,
|
||||||
eventType: "auth.login.failed",
|
eventType: "auth.login.failed",
|
||||||
eventData: {
|
eventData: {
|
||||||
email,
|
email,
|
||||||
method: "password",
|
method: "password",
|
||||||
reason: "invalid_credentials"
|
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));
|
||||||
|
await logAuditEvent({
|
||||||
|
userId: user?.id,
|
||||||
|
eventType: "auth.login.failed",
|
||||||
|
eventData: {
|
||||||
|
email,
|
||||||
|
method: "password",
|
||||||
|
reason: "invalid_credentials",
|
||||||
|
failedAttempts: user?.id
|
||||||
|
? (res.rows[0]?.failed_attempts as number)
|
||||||
|
: undefined
|
||||||
},
|
},
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
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 (
|
if (
|
||||||
!user.provider ||
|
!user.provider ||
|
||||||
!["email", "google", "github", "apple"].includes(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";
|
const expiresIn = rememberMe ? "14d" : "12h";
|
||||||
|
|
||||||
// Create session with client info (reuse clientIP from rate limiting)
|
// Create session with client info (reuse clientIP from rate limiting)
|
||||||
const userAgent =
|
const userAgent = getUserAgent(getH3Event(ctx));
|
||||||
getUserAgent(getH3Event(ctx));
|
|
||||||
const sessionId = await createSession(
|
const sessionId = await createSession(
|
||||||
user.id,
|
user.id,
|
||||||
expiresIn,
|
expiresIn,
|
||||||
@@ -929,7 +975,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: env.NODE_ENV === "production",
|
secure: env.NODE_ENV === "production",
|
||||||
sameSite: "lax"
|
sameSite: "strict"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (rememberMe) {
|
if (rememberMe) {
|
||||||
@@ -959,7 +1005,10 @@ export const authRouter = createTRPCRouter({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log the actual error for debugging
|
// Log the actual error for debugging
|
||||||
console.error("emailPasswordLogin error:", error);
|
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
|
// Re-throw TRPCErrors as-is
|
||||||
if (error instanceof TRPCError) {
|
if (error instanceof TRPCError) {
|
||||||
@@ -986,10 +1035,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
const { email, rememberMe } = input;
|
const { email, rememberMe } = input;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requested = getCookie(
|
const requested = getCookie(getH3Event(ctx), "emailLoginLinkRequested");
|
||||||
getH3Event(ctx),
|
|
||||||
"emailLoginLinkRequested"
|
|
||||||
);
|
|
||||||
if (requested) {
|
if (requested) {
|
||||||
const expires = new Date(requested);
|
const expires = new Date(requested);
|
||||||
const remaining = expires.getTime() - Date.now();
|
const remaining = expires.getTime() - Date.now();
|
||||||
@@ -1111,10 +1157,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
rateLimitPasswordReset(clientIP, getH3Event(ctx));
|
rateLimitPasswordReset(clientIP, getH3Event(ctx));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requested = getCookie(
|
const requested = getCookie(getH3Event(ctx), "passwordResetRequested");
|
||||||
getH3Event(ctx),
|
|
||||||
"passwordResetRequested"
|
|
||||||
);
|
|
||||||
if (requested) {
|
if (requested) {
|
||||||
const expires = new Date(requested);
|
const expires = new Date(requested);
|
||||||
const remaining = expires.getTime() - Date.now();
|
const remaining = expires.getTime() - Date.now();
|
||||||
@@ -1138,11 +1181,9 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const user = res.rows[0] as unknown as User;
|
const user = res.rows[0] as unknown as User;
|
||||||
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
// Create password reset token (1 hour expiry, single-use)
|
||||||
const token = await new SignJWT({ id: user.id })
|
const { token } = await createPasswordResetToken(user.id);
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime("15m")
|
|
||||||
.sign(secret);
|
|
||||||
const domain = env.VITE_DOMAIN || "https://freno.me";
|
const domain = env.VITE_DOMAIN || "https://freno.me";
|
||||||
const htmlContent = `<html>
|
const htmlContent = `<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -1176,6 +1217,9 @@ export const authRouter = createTRPCRouter({
|
|||||||
<div class="center">
|
<div class="center">
|
||||||
<a href="${domain}/login/password-reset?token=${token}" class="button">Reset Password</a>
|
<a href="${domain}/login/password-reset?token=${token}" class="button">Reset Password</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="center">
|
||||||
|
<p>This link will expire in 1 hour and can only be used once.</p>
|
||||||
|
</div>
|
||||||
<div class="center">
|
<div class="center">
|
||||||
<p>You can ignore this if you did not request this email, someone may have requested it in error</p>
|
<p>You can ignore this if you did not request this email, someone may have requested it in error</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1212,9 +1256,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
if (
|
if (
|
||||||
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
|
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = getAuditContext(
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
getH3Event(ctx)
|
|
||||||
);
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.password.reset.request",
|
eventType: "auth.password.reset.request",
|
||||||
eventData: {
|
eventData: {
|
||||||
@@ -1265,22 +1307,24 @@ export const authRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
// Validate and consume the password reset token
|
||||||
const { payload } = await jwtVerify(token, secret);
|
const tokenValidation = await validatePasswordResetToken(token);
|
||||||
|
|
||||||
if (!payload.id || typeof payload.id !== "string") {
|
if (!tokenValidation) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "bad token"
|
message: "Invalid or expired reset token"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { userId, tokenId } = tokenValidation;
|
||||||
|
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
const passwordHash = await hashPassword(newPassword);
|
const passwordHash = await hashPassword(newPassword);
|
||||||
|
|
||||||
const userRes = await conn.execute({
|
const userRes = await conn.execute({
|
||||||
sql: "SELECT provider FROM User WHERE id = ?",
|
sql: "SELECT provider FROM User WHERE id = ?",
|
||||||
args: [payload.id]
|
args: [userId]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (userRes.rows.length === 0) {
|
if (userRes.rows.length === 0) {
|
||||||
@@ -1297,16 +1341,20 @@ export const authRouter = createTRPCRouter({
|
|||||||
!["google", "github", "apple"].includes(currentProvider)
|
!["google", "github", "apple"].includes(currentProvider)
|
||||||
) {
|
) {
|
||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: "UPDATE User SET password_hash = ?, provider = ? WHERE id = ?",
|
sql: "UPDATE User SET password_hash = ?, provider = ?, failed_attempts = 0, locked_until = NULL WHERE id = ?",
|
||||||
args: [passwordHash, "email", payload.id]
|
args: [passwordHash, "email", userId]
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
|
sql: "UPDATE User SET password_hash = ?, failed_attempts = 0, locked_until = NULL WHERE id = ?",
|
||||||
args: [passwordHash, payload.id]
|
args: [passwordHash, userId]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark token as used
|
||||||
|
await markPasswordResetTokenUsed(tokenId);
|
||||||
|
|
||||||
|
// Clear authentication cookies
|
||||||
setCookie(getH3Event(ctx), "emailToken", "", {
|
setCookie(getH3Event(ctx), "emailToken", "", {
|
||||||
maxAge: 0,
|
maxAge: 0,
|
||||||
path: "/"
|
path: "/"
|
||||||
@@ -1319,7 +1367,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
// Log successful password reset
|
// Log successful password reset
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId: payload.id,
|
userId: userId,
|
||||||
eventType: "auth.password.reset.complete",
|
eventType: "auth.password.reset.complete",
|
||||||
eventData: {},
|
eventData: {},
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -1347,7 +1395,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
console.error("Password reset error:", error);
|
console.error("Password reset error:", error);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "token expired"
|
message: "Invalid or expired reset token"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -1466,9 +1514,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
if (
|
if (
|
||||||
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
|
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = getAuditContext(
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
getH3Event(ctx)
|
|
||||||
);
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.email.verify.request",
|
eventType: "auth.email.verify.request",
|
||||||
eventData: {
|
eventData: {
|
||||||
|
|||||||
@@ -400,3 +400,251 @@ export function rateLimitEmailVerification(
|
|||||||
event
|
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