feat: add TOTP multifactor authentication
This commit is contained in:
+138
-19
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user