72 lines
2.6 KiB
JavaScript
72 lines
2.6 KiB
JavaScript
// ─────────────────────────────────────────────────────────────
|
|
// src/sessions.js — one-time codes + session cookies.
|
|
//
|
|
// Codes: 6 digits, HMAC-SHA256 with a server-side pepper.
|
|
// Sessions: opaque 256-bit random IDs stored in SQLite, not JWTs.
|
|
// Revoking a session is a DELETE; no signing keys to rotate.
|
|
// ─────────────────────────────────────────────────────────────
|
|
import crypto from 'node:crypto';
|
|
import { q } from './db.js';
|
|
|
|
export const CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
export const COOKIE_NAME = 'fenja_session';
|
|
|
|
// ─── Crypto helpers ──────────────────────────────────────────
|
|
export function randomCode() {
|
|
// randomInt is cryptographically secure on Node 20+
|
|
return crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');
|
|
}
|
|
|
|
export function randomSessionId() {
|
|
return crypto.randomBytes(32).toString('hex'); // 64 hex chars
|
|
}
|
|
|
|
export function hashCode(code) {
|
|
const pepper = process.env.CODE_PEPPER;
|
|
if (!pepper || pepper.length < 32) {
|
|
throw new Error('CODE_PEPPER is missing or too short. Generate with: openssl rand -hex 32');
|
|
}
|
|
return crypto.createHmac('sha256', pepper).update(code).digest('hex');
|
|
}
|
|
|
|
export function constantTimeEqual(a, b) {
|
|
const bufA = Buffer.from(a);
|
|
const bufB = Buffer.from(b);
|
|
if (bufA.length !== bufB.length) return false;
|
|
return crypto.timingSafeEqual(bufA, bufB);
|
|
}
|
|
|
|
// ─── Session lifecycle ───────────────────────────────────────
|
|
export function issueSession(req, res, email) {
|
|
const id = randomSessionId();
|
|
const now = Date.now();
|
|
q.createSession.run(
|
|
id,
|
|
email,
|
|
now,
|
|
now + SESSION_TTL_MS,
|
|
req.ip || null,
|
|
req.get('user-agent')?.slice(0, 500) || null
|
|
);
|
|
|
|
res.cookie(COOKIE_NAME, id, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: SESSION_TTL_MS,
|
|
});
|
|
}
|
|
|
|
export function clearSession(req, res) {
|
|
const id = req.cookies?.[COOKIE_NAME];
|
|
if (id) q.deleteSession.run(id);
|
|
res.clearCookie(COOKIE_NAME, { path: '/' });
|
|
}
|
|
|
|
export function currentSession(req) {
|
|
const id = req.cookies?.[COOKIE_NAME];
|
|
if (!id) return null;
|
|
return q.getSession.get(id, Date.now()) || null;
|
|
}
|