105 lines
3.3 KiB
TypeScript
105 lines
3.3 KiB
TypeScript
import { createKV } from "./create-kv.js";
|
|
import { getCookie } from "hono/cookie";
|
|
import type { Context } from "hono";
|
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
|
|
type Session = { id: string; userId: string; expiresAt: string; createdAt: string; csrfToken?: string };
|
|
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
|
|
|
|
export const sessionsKV = createKV<Session>("sessions");
|
|
export const usersKV = createKV<User>("users");
|
|
|
|
// Cookie configuration constants
|
|
export const SESSION_COOKIE_NAME = 'sessionId';
|
|
export const CSRF_COOKIE_NAME = 'csrf-token';
|
|
export const COOKIE_OPTIONS = {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'Lax' as const,
|
|
path: '/',
|
|
maxAge: 86400 // 24 hours
|
|
};
|
|
|
|
// CSRF token generation
|
|
export function generateCSRFToken(): string {
|
|
return randomBytes(32).toString('hex');
|
|
}
|
|
|
|
// Session extraction from cookies
|
|
export async function getSessionFromCookies(c: Context): Promise<Session | null> {
|
|
const sessionId = getCookie(c, SESSION_COOKIE_NAME);
|
|
if (!sessionId) return null;
|
|
|
|
const session = await sessionsKV.getItem(sessionId);
|
|
if (!session) return null;
|
|
|
|
// Check expiration
|
|
if (new Date(session.expiresAt) < new Date()) {
|
|
// Clean up expired session
|
|
await sessionsKV.removeItem(sessionId);
|
|
return null;
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
// CSRF token validation
|
|
export async function validateCSRFToken(c: Context, sessionId: string): Promise<void> {
|
|
const headerToken = c.req.header('X-CSRF-Token');
|
|
if (!headerToken) throw new Error("CSRF token missing");
|
|
|
|
const session = await sessionsKV.getItem(sessionId);
|
|
if (!session?.csrfToken) throw new Error("Invalid session");
|
|
|
|
// Use timing-safe comparison to prevent timing attacks
|
|
const sessionTokenBuffer = Buffer.from(session.csrfToken, 'hex');
|
|
const headerTokenBuffer = Buffer.from(headerToken, 'hex');
|
|
|
|
if (sessionTokenBuffer.length !== headerTokenBuffer.length || !timingSafeEqual(sessionTokenBuffer, headerTokenBuffer)) {
|
|
throw new Error("CSRF token mismatch");
|
|
}
|
|
}
|
|
|
|
// Session rotation helper
|
|
export async function rotateSession(oldSessionId: string, userId: string): Promise<Session> {
|
|
// Delete old session
|
|
await sessionsKV.removeItem(oldSessionId);
|
|
|
|
// Create new session with CSRF token
|
|
const newSessionId = randomUUID();
|
|
const csrfToken = generateCSRFToken();
|
|
const now = new Date();
|
|
const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours
|
|
|
|
const newSession: Session = {
|
|
id: newSessionId,
|
|
userId,
|
|
expiresAt: expiresAt.toISOString(),
|
|
createdAt: now.toISOString(),
|
|
csrfToken
|
|
};
|
|
|
|
await sessionsKV.setItem(newSessionId, newSession);
|
|
return newSession;
|
|
}
|
|
|
|
// Updated assertOwner function with CSRF validation
|
|
export async function assertOwner(c: Context): Promise<void> {
|
|
const session = await getSessionFromCookies(c);
|
|
if (!session) throw new Error("Invalid session");
|
|
|
|
const user = await usersKV.getItem(session.userId);
|
|
if (!user || user.role !== "owner") throw new Error("Forbidden");
|
|
|
|
// Validate CSRF token for non-GET requests
|
|
const method = c.req.method;
|
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
await validateCSRFToken(c, session.id);
|
|
}
|
|
}
|
|
|
|
// Export types for use in other modules
|
|
export type { Session, User };
|
|
|
|
|