dea33e3f00
Ersetzt die spoofbare X-User-Id-Auth durch signierte HttpOnly-Sessions nach WebAuthn, erzwingt WRITE-only Sync, speichert den Master-Key nur im RAM und ergänzt CORS, Rate-Limits, Helmet sowie Passkey-Reauth für sensible Aktionen. Co-authored-by: Cursor <cursoragent@cursor.com>
102 lines
3.2 KiB
TypeScript
102 lines
3.2 KiB
TypeScript
import crypto from 'crypto'
|
|
import type { CookieOptions, Request, Response } from 'express'
|
|
|
|
export const SESSION_COOKIE = 'daagbok_session'
|
|
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
export const REAUTH_MAX_AGE_MS = 10 * 60 * 1000
|
|
|
|
export interface SessionPayload {
|
|
userId: string
|
|
exp: number
|
|
reauthExp?: number
|
|
}
|
|
|
|
function sessionSecret(): string {
|
|
const secret = process.env.SESSION_SECRET?.trim()
|
|
if (secret && secret.length >= 32) return secret
|
|
if (process.env.NODE_ENV === 'production') {
|
|
throw new Error('SESSION_SECRET must be set in production (min. 32 characters)')
|
|
}
|
|
return 'dev-only-insecure-session-secret-change-me!!'
|
|
}
|
|
|
|
function sign(data: string): string {
|
|
return crypto.createHmac('sha256', sessionSecret()).update(data).digest('base64url')
|
|
}
|
|
|
|
export function createSessionToken(userId: string, withReauth = true): string {
|
|
const payload: SessionPayload = {
|
|
userId,
|
|
exp: Date.now() + SESSION_MAX_AGE_MS,
|
|
...(withReauth ? { reauthExp: Date.now() + REAUTH_MAX_AGE_MS } : {})
|
|
}
|
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
const signature = sign(body)
|
|
return `${body}.${signature}`
|
|
}
|
|
|
|
export function extendReauth(token: string): string | null {
|
|
const payload = verifySessionToken(token)
|
|
if (!payload) return null
|
|
payload.reauthExp = Date.now() + REAUTH_MAX_AGE_MS
|
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
return `${body}.${sign(body)}`
|
|
}
|
|
|
|
export function verifySessionToken(token: string | undefined): SessionPayload | null {
|
|
if (!token || typeof token !== 'string') return null
|
|
const dot = token.lastIndexOf('.')
|
|
if (dot <= 0) return null
|
|
const body = token.slice(0, dot)
|
|
const sig = token.slice(dot + 1)
|
|
if (sig !== sign(body)) return null
|
|
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as SessionPayload
|
|
if (!payload.userId || typeof payload.exp !== 'number') return null
|
|
if (payload.exp <= Date.now()) return null
|
|
return payload
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function readSessionFromRequest(req: Request): SessionPayload | null {
|
|
const raw = req.cookies?.[SESSION_COOKIE]
|
|
if (typeof raw !== 'string') return null
|
|
return verifySessionToken(raw)
|
|
}
|
|
|
|
export function sessionCookieOptions(): CookieOptions {
|
|
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
|
const secure = origin.startsWith('https://')
|
|
return {
|
|
httpOnly: true,
|
|
secure,
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: SESSION_MAX_AGE_MS
|
|
}
|
|
}
|
|
|
|
export function setSessionCookie(res: Response, userId: string, withReauth = true): void {
|
|
res.cookie(SESSION_COOKIE, createSessionToken(userId, withReauth), sessionCookieOptions())
|
|
}
|
|
|
|
export function setSessionTokenCookie(res: Response, token: string): void {
|
|
res.cookie(SESSION_COOKIE, token, sessionCookieOptions())
|
|
}
|
|
|
|
export function clearSessionCookie(res: Response): void {
|
|
res.clearCookie(SESSION_COOKIE, {
|
|
httpOnly: true,
|
|
secure: sessionCookieOptions().secure,
|
|
sameSite: 'lax',
|
|
path: '/'
|
|
})
|
|
}
|
|
|
|
export function hasValidReauth(payload: SessionPayload): boolean {
|
|
return typeof payload.reauthExp === 'number' && payload.reauthExp > Date.now()
|
|
}
|