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.Field form={emailResetForm} name="email">
<Form.Control let:attrs>
<Form.Label>Email</Form.Label>
<Form.Label for="email">Email</Form.Label>
<Input
{...attrs}
type="email"
@ -77,7 +77,7 @@
<input hidden value={$tokenFormData.resetToken} name="email" />
<Form.Field form={tokenVerificationForm} name="resetToken">
<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} />
</Form.Control>
<Form.Description />

View file

@ -11,7 +11,7 @@ import db from '../../../db';
import { lucia } from '$lib/server/auth';
import { recoveryCodeSchema, totpSchema } from '$lib/validations/auth';
import { users, twoFactor, recoveryCodes } from '$db/schema';
import type { PageServerLoad } from './$types';
import type {PageServerLoad, RequestEvent} from './$types';
import { notSignedInMessage } from '$lib/flashMessages';
import env from '../../../env';
@ -92,112 +92,77 @@ const limiter = new RateLimiter({
});
export const actions: Actions = {
default: async (event) => {
if (await limiter.isLimited(event)) {
throw error(429);
}
validateTotp: 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 = await db.query.users.findFirst({
where: eq(users.username, user.username),
});
const { dbUser, twoFactorDetails } = await validateUserData(event, locals);
if (!dbUser) {
throw fail(401);
}
const totpForm = await superValidate(event, zod(totpSchema));
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);
}
const form = await superValidate(event, zod(totpSchema));
if (!form.valid) {
form.data.totpToken = '';
return fail(400, {
form,
});
if (!totpForm.valid) {
totpForm.data.totpToken = '';
return fail(400, { totpForm });
}
let sessionCookie;
try {
const totpToken = form?.data?.totpToken;
const totpToken = totpForm?.data?.totpToken;
const twoFactorSecretPopulated =
twoFactorDetails.secret !== '' && twoFactorDetails.secret !== null;
if (twoFactorDetails.enabled && !twoFactorSecretPopulated && !totpToken) {
return fail(400, {
form,
const twoFactorSecretPopulated =
twoFactorDetails.secret !== '' && twoFactorDetails.secret !== null;
if (twoFactorDetails.enabled && !twoFactorSecretPopulated && !totpToken) {
return fail(400, { totpForm });
} 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) {
// 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('totpToken', totpToken);
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.');
}
}
const message = {
type: 'error',
message: 'Two factor authentication has expired',
} as const;
redirect(302, '/login', message, event);
}
console.log('totpToken', totpToken);
const validOTP = await new TOTPController().verify(
totpToken,
decodeHex(twoFactorDetails.secret ?? ''),
);
console.log('validOTP', validOTP);
if (!validOTP) {
console.log('invalid TOTP code');
totpForm.data.totpToken = '';
return setError(totpForm, '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);
event.cookies.set(sessionCookie.name, sessionCookie.value, {
@ -205,12 +170,124 @@ export const actions: Actions = {
...sessionCookie.attributes,
});
form.data.totpToken = '';
totpForm.data.totpToken = '';
const message = { type: 'success', message: 'Signed In!' } as const;
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) {
if (initiatedTime === null || initiatedTime === undefined) {
return true;

View file

@ -15,38 +15,13 @@
const { data } = $props();
const superTotpForm = superForm(data.totpForm, {
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,
resetForm: false,
validators: zodClient(totpSchema),
validationMethod: 'oninput',
delayMs: 0,
});
const superRecoveryCodeForm = superForm(data.recoveryCodeForm, {
validators: zodClient(recoveryCodeSchema),
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',
delayMs: 0,
});
@ -78,11 +53,11 @@
</Card.Root>
{#snippet totpForm()}
<form method="POST" use:totpEnhance>
<Form.Field class="form-field-container" form={totpFormData} name="totpToken">
<form method="POST" action="?/validateTotp" use:totpEnhance>
<Form.Field class="form-field-container" form={superTotpForm} name="totpToken">
<Form.Control let:attrs>
<Form.Label for="totpToken">TOTP Code</Form.Label>
<PinInput {...attrs} bind:value={$totpFormData.totpToken} />
<Form.Label>TOTP Code</Form.Label>
<PinInput {...attrs} bind:value={$totpFormData.totpToken} class="justify-evenly" />
</Form.Control>
<Form.FieldErrors />
</Form.Field>
@ -91,10 +66,10 @@
{/snippet}
{#snippet recoveryCodeForm()}
<form method="POST" use:recoveryCodeEnhance>
<Form.Field form={recoveryCodeFormData} name="recoveryCode">
<form method="POST" action="?/validateRecoveryCode" use:recoveryCodeEnhance>
<Form.Field form={superRecoveryCodeForm} name="recoveryCode">
<Form.Control let:attrs>
<Form.Label for="totpToken">Recovery Code</Form.Label>
<Form.Label>Recovery Code</Form.Label>
<Input {...attrs} bind:value={$recoveryCodeFormData.recoveryCode} />
</Form.Control>
<Form.FieldErrors />