Fixing forms for TOTP and Recovery Code while also extracting common code.

This commit is contained in:
Bradley Shellnut 2024-07-18 19:51:34 -07:00
parent 3930c6eb12
commit aedeb7830b
3 changed files with 178 additions and 126 deletions

View file

@ -57,7 +57,7 @@
<form method="POST" action="?/passwordReset" use:emailResetEnhance class="grid gap-4"> <form method="POST" action="?/passwordReset" use:emailResetEnhance class="grid gap-4">
<Form.Field form={emailResetForm} name="email"> <Form.Field form={emailResetForm} name="email">
<Form.Control let:attrs> <Form.Control let:attrs>
<Form.Label>Email</Form.Label> <Form.Label for="email">Email</Form.Label>
<Input <Input
{...attrs} {...attrs}
type="email" type="email"
@ -77,7 +77,7 @@
<input hidden value={$tokenFormData.resetToken} name="email" /> <input hidden value={$tokenFormData.resetToken} name="email" />
<Form.Field form={tokenVerificationForm} name="resetToken"> <Form.Field form={tokenVerificationForm} name="resetToken">
<Form.Control let:attrs> <Form.Control let:attrs>
<Form.Label>Enter the token that was sent to your email</Form.Label> <Form.Label for="resetToken">Enter the token that was sent to your email</Form.Label>
<PinInput class="justify-evenly" {...attrs} bind:value={$tokenFormData.resetToken} /> <PinInput class="justify-evenly" {...attrs} bind:value={$tokenFormData.resetToken} />
</Form.Control> </Form.Control>
<Form.Description /> <Form.Description />

View file

@ -11,7 +11,7 @@ import db from '../../../db';
import { lucia } from '$lib/server/auth'; import { lucia } from '$lib/server/auth';
import { recoveryCodeSchema, totpSchema } from '$lib/validations/auth'; import { recoveryCodeSchema, totpSchema } from '$lib/validations/auth';
import { users, twoFactor, recoveryCodes } from '$db/schema'; import { users, twoFactor, recoveryCodes } from '$db/schema';
import type { PageServerLoad } from './$types'; import type {PageServerLoad, RequestEvent} from './$types';
import { notSignedInMessage } from '$lib/flashMessages'; import { notSignedInMessage } from '$lib/flashMessages';
import env from '../../../env'; import env from '../../../env';
@ -92,112 +92,77 @@ const limiter = new RateLimiter({
}); });
export const actions: Actions = { export const actions: Actions = {
default: async (event) => { validateTotp: async (event) => {
if (await limiter.isLimited(event)) {
throw error(429);
}
const { cookies, locals } = event; const { cookies, locals } = event;
const session = locals.session; const session = locals.session;
const user = locals.user; const user = locals.user;
if (await limiter.isLimited(event)) {
throw error(429);
}
if (!user || !session) { if (!user || !session) {
throw fail(401); throw fail(401);
} }
const dbUser = await db.query.users.findFirst({ const { dbUser, twoFactorDetails } = await validateUserData(event, locals);
where: eq(users.username, user.username),
});
if (!dbUser) { const totpForm = await superValidate(event, zod(totpSchema));
throw fail(401);
}
const isTwoFactorAuthenticated = session?.isTwoFactorAuthenticated; if (!totpForm.valid) {
const twoFactorDetails = await db.query.twoFactor.findFirst({ totpForm.data.totpToken = '';
where: eq(twoFactor.userId, dbUser!.id!), return fail(400, { totpForm });
});
if (!twoFactorDetails) {
const message = { type: 'error', message: 'Unable to process request' } as const;
throw redirect(302, '/login', message, event);
}
if (isTwoFactorAuthenticated && twoFactorDetails.enabled && twoFactorDetails.secret !== '') {
const message = { type: 'success', message: 'You are already signed in' } as const;
throw redirect('/', message, event);
}
const form = await superValidate(event, zod(totpSchema));
if (!form.valid) {
form.data.totpToken = '';
return fail(400, {
form,
});
} }
let sessionCookie; let sessionCookie;
try { const totpToken = totpForm?.data?.totpToken;
const totpToken = form?.data?.totpToken;
const twoFactorSecretPopulated = const twoFactorSecretPopulated =
twoFactorDetails.secret !== '' && twoFactorDetails.secret !== null; twoFactorDetails.secret !== '' && twoFactorDetails.secret !== null;
if (twoFactorDetails.enabled && !twoFactorSecretPopulated && !totpToken) { if (twoFactorDetails.enabled && !twoFactorSecretPopulated && !totpToken) {
return fail(400, { return fail(400, { totpForm });
form, } else if (twoFactorSecretPopulated && totpToken) {
// Check if two factor started less than TWO_FACTOR_TIMEOUT
const totpElapsed = totpTimeElapsed(twoFactorDetails.initiatedTime ?? new Date());
if (totpElapsed) {
await lucia.invalidateSession(session!.id!);
const sessionCookie = lucia.createBlankSessionCookie();
cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes,
}); });
} else if (twoFactorSecretPopulated && totpToken) { const message = {
// Check if two factor started less than TWO_FACTOR_TIMEOUT type: 'error',
const totpElapsed = totpTimeElapsed(twoFactorDetails.initiatedTime ?? new Date()); message: 'Two factor authentication has expired',
if (totpElapsed) { } as const;
await lucia.invalidateSession(session!.id!); redirect(302, '/login', message, event);
const sessionCookie = lucia.createBlankSessionCookie(); }
cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.', console.log('totpToken', totpToken);
...sessionCookie.attributes, const validOTP = await new TOTPController().verify(
}); totpToken,
const message = { decodeHex(twoFactorDetails.secret ?? ''),
type: 'error', );
message: 'Two factor authentication has expired', console.log('validOTP', validOTP);
} as const;
redirect(302, '/login', message, event); if (!validOTP) {
} console.log('invalid TOTP code');
totpForm.data.totpToken = '';
console.log('totpToken', totpToken); return setError(totpForm, 'totpToken', 'Invalid code.');
const validOTP = await new TOTPController().verify(
totpToken,
decodeHex(twoFactorDetails.secret ?? ''),
);
console.log('validOTP', validOTP);
if (!validOTP) {
console.log('invalid TOTP code check for recovery codes');
const usedRecoveryCode = await checkRecoveryCode(totpToken, dbUser.id);
if (!usedRecoveryCode) {
console.log('invalid TOTP code');
form.data.totpToken = '';
return setError(form, 'totpToken', 'Invalid code.');
}
}
} }
console.log('ip', locals.ip);
console.log('country', locals.country);
await lucia.invalidateSession(session.id);
const newSession = await lucia.createSession(dbUser.id, {
ip_country: locals.country,
ip_address: locals.ip,
twoFactorAuthEnabled: true,
isTwoFactorAuthenticated: true,
});
console.log('logging in session', newSession);
sessionCookie = lucia.createSessionCookie(newSession.id);
console.log('logging in session cookie', sessionCookie);
} catch (e) {
// TODO: need to return error message to the client
console.error(e);
return setError(form, 'totpToken', 'Error verifying TOTP code.');
} }
console.log('ip', locals.ip);
console.log('country', locals.country);
await lucia.invalidateSession(session.id);
const newSession = await lucia.createSession(dbUser.id, {
ip_country: locals.country,
ip_address: locals.ip,
twoFactorAuthEnabled: true,
isTwoFactorAuthenticated: true,
});
console.log('logging in session', newSession);
sessionCookie = lucia.createSessionCookie(newSession.id);
console.log('logging in session cookie', sessionCookie);
console.log('setting session cookie', sessionCookie); console.log('setting session cookie', sessionCookie);
event.cookies.set(sessionCookie.name, sessionCookie.value, { event.cookies.set(sessionCookie.name, sessionCookie.value, {
@ -205,12 +170,124 @@ export const actions: Actions = {
...sessionCookie.attributes, ...sessionCookie.attributes,
}); });
form.data.totpToken = ''; totpForm.data.totpToken = '';
const message = { type: 'success', message: 'Signed In!' } as const; const message = { type: 'success', message: 'Signed In!' } as const;
redirect(302, '/', message, event); redirect(302, '/', message, event);
}, },
validateRecoveryCode: async (event) => {
const { cookies, locals } = event;
const session = locals.session;
const user = locals.user;
if (await limiter.isLimited(event)) {
throw error(429);
}
if (!user || !session) {
throw fail(401);
}
const { dbUser, twoFactorDetails } = await validateUserData(event, locals);
const recoveryCodeForm = await superValidate(event, zod(recoveryCodeSchema));
if (!recoveryCodeForm.valid) {
return fail(400, {
form: recoveryCodeForm,
});
}
let sessionCookie;
const recoveryCode = recoveryCodeForm?.data?.recoveryCode;
const twoFactorSecretPopulated =
twoFactorDetails.secret !== '' && twoFactorDetails.secret !== null;
if (twoFactorDetails.enabled && !twoFactorSecretPopulated && !recoveryCode) {
return fail(400, { recoveryCodeForm });
} else if (twoFactorSecretPopulated && recoveryCode) {
// Check if two factor started less than TWO_FACTOR_TIMEOUT
const totpElapsed = totpTimeElapsed(twoFactorDetails.initiatedTime ?? new Date());
if (totpElapsed) {
await lucia.invalidateSession(session!.id!);
const sessionCookie = lucia.createBlankSessionCookie();
cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes,
});
const message = {
type: 'error',
message: 'Two factor authentication has expired',
} as const;
redirect(302, '/login', message, event);
}
console.log('recoveryCode', recoveryCode);
console.log('Check for recovery codes');
const usedRecoveryCode = await checkRecoveryCode(recoveryCode, dbUser.id);
if (!usedRecoveryCode) {
console.log('invalid recovery code');
recoveryCodeForm.data.recoveryCode = '';
return setError(recoveryCodeForm, 'recoveryCode', 'Invalid code.');
}
}
console.log('ip', locals.ip);
console.log('country', locals.country);
await lucia.invalidateSession(session.id);
const newSession = await lucia.createSession(dbUser.id, {
ip_country: locals.country,
ip_address: locals.ip,
twoFactorAuthEnabled: true,
isTwoFactorAuthenticated: true,
});
console.log('logging in session', newSession);
sessionCookie = lucia.createSessionCookie(newSession.id);
console.log('logging in session cookie', sessionCookie);
console.log('setting session cookie', sessionCookie);
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes,
});
recoveryCodeForm.data.recoveryCode = '';
const message = { type: 'success', message: 'Signed In!' } as const;
redirect(302, '/', message, event);
}
}; };
async function validateUserData(event: RequestEvent, locals: App.Locals) {
const { user, session } = locals;
if (!user || !session) {
throw fail(401);
}
const dbUser = await db.query.users.findFirst({
where: eq(users.username, user.username),
});
if (!dbUser) {
throw fail(401);
}
const isTwoFactorAuthenticated = session?.isTwoFactorAuthenticated;
const twoFactorDetails = await db.query.twoFactor.findFirst({
where: eq(twoFactor.userId, dbUser!.id!),
});
if (!twoFactorDetails) {
const message = {type: 'error', message: 'Unable to process request'} as const;
throw redirect(302, '/login', message, event);
}
if (isTwoFactorAuthenticated && twoFactorDetails.enabled && twoFactorDetails.secret !== '') {
const message = {type: 'success', message: 'You are already signed in'} as const;
throw redirect('/', message, event);
}
return { dbUser, twoFactorDetails };
}
function totpTimeElapsed(initiatedTime: Date) { function totpTimeElapsed(initiatedTime: Date) {
if (initiatedTime === null || initiatedTime === undefined) { if (initiatedTime === null || initiatedTime === undefined) {
return true; return true;

View file

@ -15,38 +15,13 @@
const { data } = $props(); const { data } = $props();
const superTotpForm = superForm(data.totpForm, { const superTotpForm = superForm(data.totpForm, {
flashMessage: { resetForm: false,
module: flashModule,
onError: ({ result, flashMessage }) => {
// Error handling for the flash message:
// - result is the ActionResult
// - message is the flash store (not the status message store)
const errorMessage = result.error.message
flashMessage.set({ type: 'error', message: errorMessage });
}
},
syncFlashMessage: false,
taintedMessage: null,
validators: zodClient(totpSchema), validators: zodClient(totpSchema),
validationMethod: 'oninput',
delayMs: 0,
}); });
const superRecoveryCodeForm = superForm(data.recoveryCodeForm, { const superRecoveryCodeForm = superForm(data.recoveryCodeForm, {
validators: zodClient(recoveryCodeSchema), validators: zodClient(recoveryCodeSchema),
resetForm: false, resetForm: false,
flashMessage: {
module: flashModule,
onError: ({ result, flashMessage }) => {
// Error handling for the flash message:
// - result is the ActionResult
// - message is the flash store (not the status message store)
const errorMessage = result.error.message
flashMessage.set({ type: 'error', message: errorMessage });
}
},
syncFlashMessage: false,
taintedMessage: null,
validationMethod: 'oninput', validationMethod: 'oninput',
delayMs: 0, delayMs: 0,
}); });
@ -78,11 +53,11 @@
</Card.Root> </Card.Root>
{#snippet totpForm()} {#snippet totpForm()}
<form method="POST" use:totpEnhance> <form method="POST" action="?/validateTotp" use:totpEnhance>
<Form.Field class="form-field-container" form={totpFormData} name="totpToken"> <Form.Field class="form-field-container" form={superTotpForm} name="totpToken">
<Form.Control let:attrs> <Form.Control let:attrs>
<Form.Label for="totpToken">TOTP Code</Form.Label> <Form.Label>TOTP Code</Form.Label>
<PinInput {...attrs} bind:value={$totpFormData.totpToken} /> <PinInput {...attrs} bind:value={$totpFormData.totpToken} class="justify-evenly" />
</Form.Control> </Form.Control>
<Form.FieldErrors /> <Form.FieldErrors />
</Form.Field> </Form.Field>
@ -91,10 +66,10 @@
{/snippet} {/snippet}
{#snippet recoveryCodeForm()} {#snippet recoveryCodeForm()}
<form method="POST" use:recoveryCodeEnhance> <form method="POST" action="?/validateRecoveryCode" use:recoveryCodeEnhance>
<Form.Field form={recoveryCodeFormData} name="recoveryCode"> <Form.Field form={superRecoveryCodeForm} name="recoveryCode">
<Form.Control let:attrs> <Form.Control let:attrs>
<Form.Label for="totpToken">Recovery Code</Form.Label> <Form.Label>Recovery Code</Form.Label>
<Input {...attrs} bind:value={$recoveryCodeFormData.recoveryCode} /> <Input {...attrs} bind:value={$recoveryCodeFormData.recoveryCode} />
</Form.Control> </Form.Control>
<Form.FieldErrors /> <Form.FieldErrors />