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