116 lines
3.7 KiB
TypeScript
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;
|