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.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 />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Reference in a new issue