Refactor login flow with reset token support
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m6s

This commit is contained in:
Elijah 2026-07-12 07:53:48 -07:00
parent f0548bcc6e
commit 95af276d2e
11 changed files with 679 additions and 226 deletions

View file

@ -3,6 +3,12 @@ import { cookies } from "next/headers";
export interface SessionData {
isAuthenticated: boolean;
sessionGeneration?: number;
}
interface PasswordResetSessionData {
nonce?: string;
expiresAt?: string;
}
const sessionOptions: SessionOptions = {
@ -15,14 +21,27 @@ const sessionOptions: SessionOptions = {
},
};
const passwordResetSessionOptions: SessionOptions = {
password: sessionOptions.password,
cookieName: "study-app-password-reset",
cookieOptions: {
secure: sessionOptions.cookieOptions?.secure,
httpOnly: true,
sameSite: "lax" as const,
maxAge: 15 * 60,
path: "/",
},
};
export async function getSession() {
const cookieStore = await cookies();
return getIronSession<SessionData>(cookieStore, sessionOptions);
}
export async function createSession() {
export async function createSession(sessionGeneration: number) {
const session = await getSession();
session.isAuthenticated = true;
session.sessionGeneration = sessionGeneration;
await session.save();
}
@ -35,3 +54,23 @@ export async function isAuthenticated() {
const session = await getSession();
return session.isAuthenticated === true;
}
export async function getPasswordResetSession() {
const cookieStore = await cookies();
return getIronSession<PasswordResetSessionData>(
cookieStore,
passwordResetSessionOptions
);
}
export async function authorizePasswordReset(nonce: string, expiresAt: string) {
const session = await getPasswordResetSession();
session.nonce = nonce;
session.expiresAt = expiresAt;
await session.save();
}
export async function destroyPasswordResetAuthorization() {
const session = await getPasswordResetSession();
session.destroy();
}

View file

@ -12,20 +12,25 @@ const store = new Map<string, RateLimitEntry>();
const WINDOW_MS = 60 * 1000; // 1 minute
const MAX_REQUESTS = 10;
export function checkRateLimit(ip: string): { allowed: boolean; retryAfterMs: number } {
export function checkRateLimit(
key: string,
options: { windowMs?: number; maxRequests?: number } = {}
): { allowed: boolean; retryAfterMs: number } {
const now = Date.now();
const entry = store.get(ip) ?? { timestamps: [] };
const windowMs = options.windowMs ?? WINDOW_MS;
const maxRequests = options.maxRequests ?? MAX_REQUESTS;
const entry = store.get(key) ?? { timestamps: [] };
// Remove timestamps outside the window
entry.timestamps = entry.timestamps.filter((t) => now - t < WINDOW_MS);
entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
if (entry.timestamps.length >= MAX_REQUESTS) {
if (entry.timestamps.length >= maxRequests) {
const oldestInWindow = entry.timestamps[0];
const retryAfterMs = WINDOW_MS - (now - oldestInWindow);
const retryAfterMs = windowMs - (now - oldestInWindow);
return { allowed: false, retryAfterMs };
}
entry.timestamps.push(now);
store.set(ip, entry);
store.set(key, entry);
return { allowed: true, retryAfterMs: 0 };
}

View file

@ -0,0 +1,19 @@
import { z } from "zod";
export const loginSchema = z.object({
password: z.string().min(8, "Password must be at least 8 characters"),
});
export const resetTokenSchema = z.object({
token: z.string().min(1, "Reset token is required"),
});
export const completePasswordResetSchema = z
.object({
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((value) => value.password === value.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});