feat: add TOTP multifactor authentication

This commit is contained in:
jb
2026-08-07 13:10:05 -04:00
parent 4777a3abd5
commit e67c3d4cdb
50 changed files with 6646 additions and 539 deletions
+138 -19
View File
@@ -1,5 +1,6 @@
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';
@@ -16,6 +17,27 @@ export type AuthUser = {
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 = {
@@ -25,23 +47,43 @@ type LoginRequestContext = {
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');
}
export async function createSession(
cookies: Cookies,
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,
): Promise<string> {
const database = await getDatabase();
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 insert = await database.prepare(
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)',
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(
@@ -50,16 +92,67 @@ export async function createSession(
hashToken(token),
expiresAt.toISOString(),
now.toISOString(),
aal,
aalVerifiedAt,
aal2VerifiedAt,
);
cookies.set(COOKIE_NAME, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
maxAge: SESSION_SECONDS,
});
return sessionId;
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) {
@@ -110,6 +203,23 @@ export async function recordLogin(
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;
@@ -134,10 +244,10 @@ export async function recordLogin(
/* unavailable in some development adapters */
}
await (
await database.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
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,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
failure_reason,risk_flags,credential_id,session_id,aal,aal2_verified_at,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
randomUUID(),
userId,
@@ -155,9 +265,10 @@ export async function recordLogin(
riskFlags.length ? riskFlags.join(',') : null,
context.credentialId ?? null,
context.sessionId ?? null,
context.aal ?? (succeeded ? 1 : null),
context.aal2VerifiedAt ?? null,
new Date().toISOString(),
);
await purgeExpiredLoginMetadata();
}
export async function getSessionUser(
@@ -173,7 +284,9 @@ export async function getSessionUser(
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
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
@@ -224,6 +337,12 @@ export async function getSessionUser(
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,
};
}