play/src/lib/server/auth/session.ts

116 lines
3.7 KiB
TypeScript

// /var/www/rp/src/lib/server/auth/session.ts
// Signed cookie sessions. SESSION_SECRET must be set in .env.
// Session ID is a random 32-byte hex string. The session row in the DB
// carries the user_id and expires_at. The cookie carries only the session ID.
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { db } from '../db';
const SESSION_TTL_DAYS = 30;
const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
const COOKIE_NAME = 'thw_play_session';
function getSessionSecret(): string {
const s = process.env.SESSION_SECRET;
if (!s || s.length < 32) {
throw new Error('SESSION_SECRET must be set in .env (32+ chars)');
}
return s;
}
function sign(value: string): string {
return createHmac('sha256', getSessionSecret()).update(value).digest('hex');
}
function makeCookieValue(sessionId: string): string {
return `${sessionId}.${sign(sessionId)}`;
}
function verifyCookieValue(cookieValue: string): string | null {
const dotIdx = cookieValue.lastIndexOf('.');
if (dotIdx < 1) return null;
const sessionId = cookieValue.slice(0, dotIdx);
const providedSig = cookieValue.slice(dotIdx + 1);
const expectedSig = sign(sessionId);
if (providedSig.length !== expectedSig.length) return null;
try {
if (!timingSafeEqual(Buffer.from(providedSig, 'hex'), Buffer.from(expectedSig, 'hex'))) {
return null;
}
} catch {
return null;
}
return sessionId;
}
export interface SessionUser {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
email: string | null;
display_name: string | null;
created_at?: string;
last_login_at?: string;
}
export function createSession(userId: string): { id: string; cookie: string; expiresAt: Date } {
const id = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').run(
id,
userId,
expiresAt.toISOString()
);
return { id, cookie: makeCookieValue(id), expiresAt };
}
export function destroySession(sessionId: string): void {
db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
}
export function getSessionUser(cookieValue: string | undefined | null): SessionUser | null {
if (!cookieValue) return null;
const sessionId = verifyCookieValue(cookieValue);
if (!sessionId) return null;
const row = db
.prepare(
`SELECT u.id, u.username, u.global_name, u.avatar, u.email, u.display_name,
u.created_at, u.last_login_at, s.expires_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > datetime('now')`
)
.get(sessionId) as
| (SessionUser & { expires_at: string })
| undefined;
if (!row) return null;
return {
id: row.id,
username: row.username,
global_name: row.global_name,
avatar: row.avatar,
email: row.email,
display_name: row.display_name ?? null,
created_at: row.created_at,
last_login_at: row.last_login_at
};
}
export function upsertUser(user: SessionUser): void {
db.prepare(
`INSERT INTO users (id, username, global_name, avatar, email, display_name, last_login_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
username = excluded.username,
global_name = excluded.global_name,
avatar = excluded.avatar,
email = excluded.email,
display_name = excluded.display_name,
last_login_at = datetime('now')`
).run(user.id, user.username, user.global_name, user.avatar, user.email, user.display_name ?? null);
}
export const SESSION_COOKIE = COOKIE_NAME;
export const SESSION_TTL_DURATION = SESSION_TTL_DAYS * 24 * 60 * 60;