mirror of
https://github.com/BradNut/boredgame
synced 2025-09-08 17:40:22 +00:00
Fixing forms for TOTP and Recovery Code while also extracting common code.
This commit is contained in:
parent
3930c6eb12
commit
aedeb7830b
3 changed files with 178 additions and 126 deletions
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue