import { dev } from '$app/environment'; import { getDatabase } from '$lib/server/database'; import type { Cookies } from '@sveltejs/kit'; import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { avatarUrl } from '$lib/avatar'; const COOKIE_NAME = 'shd_session'; const SESSION_SECONDS = 60 * 60 * 24 * 30; export type AuthUser = { id: string; username: string; loginUsername: string | null; displayName: string; publicSlug: string | null; role: string; discordLinked: boolean; avatarUrl: string; }; type LoginRequestContext = { request?: Request; getClientAddress?: () => string; failureReason?: string | null; credentialId?: string | null; sessionId?: string | null; riskFlags?: string[] | null; }; function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } export async function createSession( cookies: Cookies, userId: string, ): Promise { const database = await getDatabase(); const token = randomBytes(32).toString('base64url'); const sessionId = randomUUID(); const now = new Date(); const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000); const insert = await database.prepare( 'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)', ); await insert.run( sessionId, userId, hashToken(token), expiresAt.toISOString(), now.toISOString(), ); cookies.set(COOKIE_NAME, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: !dev, maxAge: SESSION_SECONDS, }); return sessionId; } function describeClient(userAgent: string) { const clientName = /Edg\//.test(userAgent) ? 'Edge' : /Firefox\//.test(userAgent) ? 'Firefox' : /Chrome\//.test(userAgent) ? 'Chrome' : /Safari\//.test(userAgent) ? 'Safari' : 'Other'; const operatingSystem = /Windows/.test(userAgent) ? 'Windows' : /Android/.test(userAgent) ? 'Android' : /iPhone|iPad/.test(userAgent) ? 'iOS/iPadOS' : /Mac OS X/.test(userAgent) ? 'macOS' : /Linux/.test(userAgent) ? 'Linux' : 'Other'; const deviceType = /Mobile|Android|iPhone/.test(userAgent) ? 'Mobile' : /iPad|Tablet/.test(userAgent) ? 'Tablet' : 'Desktop'; return { clientName, operatingSystem, deviceType }; } export async function purgeExpiredLoginMetadata(): Promise { const database = await getDatabase(); const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); const now = new Date().toISOString(); await ( await database.prepare(`UPDATE login_events SET ip_address=NULL,user_agent=NULL,client_name=NULL, operating_system=NULL,device_type=NULL,location_country=NULL,location_region=NULL,location_city=NULL, credential_id=NULL,session_id=NULL,sensitive_purged_at=? WHERE created_at { const database = await getDatabase(); const userAgent = context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null; const headers = context.request?.headers; const locationCountry = headers?.get('cf-ipcountry') ?? headers?.get('x-vercel-ip-country') ?? headers?.get('x-geo-country'); const locationRegion = headers?.get('x-vercel-ip-country-region') ?? headers?.get('x-geo-region'); const locationCity = headers?.get('x-vercel-ip-city') ?? headers?.get('x-geo-city'); const client = describeClient(userAgent ?? ''); const riskFlags = context.riskFlags ?? (!succeeded ? [userId ? 'failed_authentication' : 'unknown_account_attempt'] : []); let ipAddress: string | null = null; try { ipAddress = context.getClientAddress?.() ?? null; } catch { /* unavailable in some development adapters */ } await ( await database.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent, client_name,operating_system,device_type,location_country,location_region,location_city, failure_reason,risk_flags,credential_id,session_id,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) ).run( randomUUID(), userId, provider, succeeded ? 1 : 0, ipAddress, userAgent, client.clientName, client.operatingSystem, client.deviceType, locationCountry?.slice(0, 100) ?? null, locationRegion?.slice(0, 100) ?? null, locationCity?.slice(0, 100) ?? null, context.failureReason ?? null, riskFlags.length ? riskFlags.join(',') : null, context.credentialId ?? null, context.sessionId ?? null, new Date().toISOString(), ); await purgeExpiredLoginMetadata(); } export async function getSessionUser( cookies: Cookies, ): Promise { const token = cookies.get(COOKIE_NAME); if (!token) return null; const database = await getDatabase(); const statement = await database.prepare(` SELECT users.id, users.username, users.login_username, COALESCE(user_profiles.display_name,users.username) AS display_name, user_profiles.public_slug, users.role, users.discord_id, user_avatars.source AS avatar_source, user_avatars.value AS avatar_value, user_avatars.updated_at AS avatar_updated_at, user_status.last_active_at,user_status.blocked_at,user_status.banned_until, deleted_accounts.deleted_at FROM sessions JOIN users ON users.id = sessions.user_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id LEFT JOIN user_avatars ON user_avatars.user_id=users.id LEFT JOIN user_status ON user_status.user_id=users.id LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE sessions.token_hash = ? AND sessions.expires_at > ? `); const user = await statement.get(hashToken(token), new Date().toISOString()); if (!user) { cookies.delete(COOKIE_NAME, { path: '/' }); return null; } const now = new Date(); const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > now; if (user.deleted_at || user.blocked_at || banned) { await ( await database.prepare('DELETE FROM sessions WHERE token_hash=?') ).run(hashToken(token)); cookies.delete(COOKIE_NAME, { path: '/' }); return null; } if ( !user.last_active_at || now.getTime() - new Date(String(user.last_active_at)).getTime() > 5 * 60 * 1000 ) { await ( await database.prepare(`INSERT INTO user_status(user_id,last_active_at,updated_at) VALUES(?,?,?) ON CONFLICT(user_id) DO UPDATE SET last_active_at=excluded.last_active_at,updated_at=excluded.updated_at`) ).run(String(user.id), now.toISOString(), now.toISOString()); } return { id: String(user.id), username: String(user.username), loginUsername: typeof user.login_username === 'string' ? user.login_username : null, displayName: String(user.display_name), publicSlug: typeof user.public_slug === 'string' ? user.public_slug : null, role: String(user.role), discordLinked: typeof user.discord_id === 'string', avatarUrl: avatarUrl( user.avatar_source, user.avatar_value, String(user.id), user.avatar_updated_at, ), }; } export async function destroySession(cookies: Cookies): Promise { const token = cookies.get(COOKIE_NAME); if (token) { const database = await getDatabase(); const session = await ( await database.prepare('SELECT id FROM sessions WHERE token_hash=?') ).get(hashToken(token)); if (session?.id) { await ( await database.prepare( 'UPDATE login_events SET session_ended_at=?,session_end_reason=? WHERE session_id=?', ) ).run(new Date().toISOString(), 'user_logout', session.id); } const statement = await database.prepare( 'DELETE FROM sessions WHERE token_hash = ?', ); await statement.run(hashToken(token)); } cookies.delete(COOKIE_NAME, { path: '/' }); }