121 lines
3.8 KiB
TypeScript
121 lines
3.8 KiB
TypeScript
import type { Database } from '@tursodatabase/database';
|
|
import type { Cookies } from '@sveltejs/kit';
|
|
import { hasActiveTotpFactor, isFullAdministrator } from './bootstrap';
|
|
import {
|
|
createAuthenticationTransaction,
|
|
type AuthenticationTransaction,
|
|
} from './authentication-transactions';
|
|
import { createSession, recordLogin, type SessionAuthLevel } from './session';
|
|
|
|
export type PrimaryAuthenticationProvider =
|
|
'password' | 'discord' | 'magic_link' | 'passkey';
|
|
|
|
export class PrimaryAuthenticationError extends Error {
|
|
readonly code: 'account_restricted' | 'account_missing';
|
|
|
|
constructor(code: 'account_restricted' | 'account_missing') {
|
|
super('This account is not permitted to sign in.');
|
|
this.name = 'PrimaryAuthenticationError';
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
export type PrimaryAuthenticationResult =
|
|
| {
|
|
kind: 'session';
|
|
sessionId: string;
|
|
aal: SessionAuthLevel;
|
|
redirectTo: string;
|
|
}
|
|
| {
|
|
kind: 'mfa';
|
|
transaction: AuthenticationTransaction;
|
|
redirectTo: '/auth/two-factor';
|
|
};
|
|
|
|
export type CompletePrimaryAuthenticationInput = {
|
|
database: Database;
|
|
cookies: Cookies;
|
|
userId: string;
|
|
provider: PrimaryAuthenticationProvider;
|
|
request?: Request;
|
|
getClientAddress?: () => string;
|
|
credentialId?: string | null;
|
|
returnPath?: string;
|
|
};
|
|
|
|
/**
|
|
* Completes a verified password, email, Discord, or passkey identity. This is
|
|
* the only primary-auth path allowed to create a session. Accounts with an
|
|
* active TOTP factor receive an opaque MFA transaction instead.
|
|
*/
|
|
export async function completePrimaryAuthentication(
|
|
input: CompletePrimaryAuthenticationInput,
|
|
): Promise<PrimaryAuthenticationResult> {
|
|
const status = await (
|
|
await input.database.prepare(`
|
|
SELECT users.id,user_status.blocked_at,user_status.banned_until,
|
|
deleted_accounts.deleted_at
|
|
FROM users
|
|
LEFT JOIN user_status ON user_status.user_id=users.id
|
|
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
|
WHERE users.id=?
|
|
`)
|
|
).get(input.userId);
|
|
const banned =
|
|
typeof status?.banned_until === 'string' &&
|
|
new Date(status.banned_until) > new Date();
|
|
if (!status) throw new PrimaryAuthenticationError('account_missing');
|
|
if (status.deleted_at || status.blocked_at || banned) {
|
|
await recordLogin(input.userId, input.provider, false, {
|
|
request: input.request,
|
|
getClientAddress: input.getClientAddress,
|
|
credentialId: input.credentialId,
|
|
failureReason: 'account_restricted',
|
|
});
|
|
throw new PrimaryAuthenticationError('account_restricted');
|
|
}
|
|
|
|
const activeFactor = await hasActiveTotpFactor(input.database, input.userId);
|
|
const fullAdministrator = await isFullAdministrator(
|
|
input.database,
|
|
input.userId,
|
|
);
|
|
if (!activeFactor) {
|
|
const sessionId = await createSession(input.cookies, input.userId, {
|
|
aal: 1,
|
|
});
|
|
const redirectTo = fullAdministrator
|
|
? '/account/security?required=admin'
|
|
: (input.returnPath ?? '/account');
|
|
await recordLogin(input.userId, input.provider, true, {
|
|
request: input.request,
|
|
getClientAddress: input.getClientAddress,
|
|
credentialId: input.credentialId,
|
|
sessionId,
|
|
aal: 1,
|
|
});
|
|
return { kind: 'session', sessionId, aal: 1, redirectTo };
|
|
}
|
|
|
|
const transaction = await createAuthenticationTransaction(
|
|
input.cookies,
|
|
input.database,
|
|
{
|
|
userId: input.userId,
|
|
kind: 'login',
|
|
provider: input.provider,
|
|
returnPath: input.returnPath ?? '/account',
|
|
},
|
|
);
|
|
await recordLogin(input.userId, input.provider, false, {
|
|
request: input.request,
|
|
getClientAddress: input.getClientAddress,
|
|
credentialId: input.credentialId,
|
|
failureReason: 'mfa_required',
|
|
riskFlags: ['primary_authenticated_mfa_required'],
|
|
aal: 1,
|
|
});
|
|
return { kind: 'mfa', transaction, redirectTo: '/auth/two-factor' };
|
|
}
|