Files
shd.build/src/lib/server/auth/session.ts
T

372 lines
12 KiB
TypeScript

import { dev } from '$app/environment';
import { getDatabase } from '$lib/server/database';
import type { Database } from '@tursodatabase/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;
/** Server-only authentication assurance metadata; no raw token is exposed. */
aal: 1 | 2;
authLevel: 1 | 2;
sessionCreatedAt: string;
aalVerifiedAt: string;
aal2VerifiedAt: string | null;
};
export type SessionAuthLevel = 1 | 2;
export type SessionTransaction = Pick<Database, 'prepare'>;
export type PendingSession = {
sessionId: string;
token: string;
};
export type CreateSessionOptions = {
aal?: SessionAuthLevel;
authLevel?: SessionAuthLevel;
aalVerifiedAt?: Date | string;
aal2VerifiedAt?: Date | string | null;
};
type LoginRequestContext = {
request?: Request;
getClientAddress?: () => string;
failureReason?: string | null;
credentialId?: string | null;
sessionId?: string | null;
riskFlags?: string[] | null;
aal?: SessionAuthLevel;
aal2VerifiedAt?: string | null;
};
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function sessionCookieOptions() {
return {
path: '/',
httpOnly: true,
sameSite: 'lax' as const,
secure: !dev,
maxAge: SESSION_SECONDS,
};
}
export function setSessionCookie(cookies: Cookies, token: string): void {
cookies.set(COOKIE_NAME, token, sessionCookieOptions());
}
export async function createSessionInTransaction(
transaction: SessionTransaction,
userId: string,
options: CreateSessionOptions = {},
): Promise<PendingSession> {
const token = randomBytes(32).toString('base64url');
const sessionId = randomUUID();
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
const aal = options.authLevel ?? options.aal ?? 1;
const aalVerifiedAt = new Date(options.aalVerifiedAt ?? now).toISOString();
const aal2VerifiedAt =
aal === 2 ? new Date(options.aal2VerifiedAt ?? now).toISOString() : null;
const insert = await transaction.prepare(
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at, aal, aal_verified_at, aal2_verified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
);
await insert.run(
sessionId,
userId,
hashToken(token),
expiresAt.toISOString(),
now.toISOString(),
aal,
aalVerifiedAt,
aal2VerifiedAt,
);
return { sessionId, token };
}
export async function createSession(
cookies: Cookies,
userId: string,
options: CreateSessionOptions = {},
): Promise<string> {
const database = await getDatabase();
const operation = database.transactionAsync((transaction) =>
createSessionInTransaction(transaction, userId, options),
);
const session = await operation.immediate();
setSessionCookie(cookies, session.token);
return session.sessionId;
}
/** Upgrade the current cookie-backed session without exposing or reissuing its token. */
export async function upgradeSessionToAal2InTransaction(
transaction: SessionTransaction,
cookies: Cookies,
userId: string,
verifiedAt = new Date(),
): Promise<string | null> {
const token = cookies.get(COOKIE_NAME);
if (!token) return null;
const result = await (
await transaction.prepare(`
UPDATE sessions
SET aal=2,aal_verified_at=?,aal2_verified_at=?
WHERE token_hash=? AND user_id=? AND expires_at>?
`)
).run(
verifiedAt.toISOString(),
verifiedAt.toISOString(),
hashToken(token),
userId,
new Date().toISOString(),
);
if (result.changes !== 1) return null;
const row = await (
await transaction.prepare('SELECT id FROM sessions WHERE token_hash=?')
).get(hashToken(token));
return row?.id ? String(row.id) : null;
}
export async function upgradeSessionToAal2(
cookies: Cookies,
userId: string,
verifiedAt = new Date(),
): Promise<string | null> {
const database = await getDatabase();
const operation = database.transactionAsync((transaction) =>
upgradeSessionToAal2InTransaction(transaction, cookies, userId, verifiedAt),
);
return await operation.immediate();
}
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<void> {
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<? AND sensitive_purged_at IS NULL`)
).run(now, cutoff);
}
export async function recordLogin(
userId: string | null,
provider: 'password' | 'discord' | 'magic_link' | 'passkey',
succeeded: boolean,
context: LoginRequestContext = {},
): Promise<void> {
const database = await getDatabase();
await recordLoginInTransaction(
database,
userId,
provider,
succeeded,
context,
);
await purgeExpiredLoginMetadata();
}
export async function recordLoginInTransaction(
transaction: SessionTransaction,
userId: string | null,
provider: 'password' | 'discord' | 'magic_link' | 'passkey',
succeeded: boolean,
context: LoginRequestContext = {},
): Promise<void> {
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 transaction.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,aal,aal2_verified_at,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,
context.aal ?? (succeeded ? 1 : null),
context.aal2VerifiedAt ?? null,
new Date().toISOString(),
);
}
export async function getSessionUser(
cookies: Cookies,
): Promise<AuthUser | null> {
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,
sessions.aal,sessions.aal_verified_at,sessions.aal2_verified_at,
sessions.created_at AS session_created_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,
),
aal: Number(user.aal) === 2 ? 2 : 1,
authLevel: Number(user.aal) === 2 ? 2 : 1,
sessionCreatedAt: String(user.session_created_at),
aalVerifiedAt: String(user.aal_verified_at),
aal2VerifiedAt:
typeof user.aal2_verified_at === 'string' ? user.aal2_verified_at : null,
};
}
export async function destroySession(cookies: Cookies): Promise<void> {
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: '/' });
}