mirror of
https://github.com/BradNut/boredgame
synced 2025-09-08 17:40:22 +00:00
Refactor to use recovery code on TOTP as separate form.
This commit is contained in:
parent
16ba22c76d
commit
091bcd2e88
5 changed files with 104 additions and 80 deletions
|
|
@ -25,5 +25,9 @@ export const signInSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const totpSchema = z.object({
|
export const totpSchema = z.object({
|
||||||
totpToken: z.string().trim().min(6).max(10),
|
totpToken: z.string().trim().min(6).max(6),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const recoveryCodeSchema = z.object({
|
||||||
|
recoveryCode: z.string().trim().min(10).max(10),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { fail, error, type Actions } from '@sveltejs/kit';
|
import { fail, error, type Actions } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, or } from 'drizzle-orm';
|
||||||
import { Argon2id } from 'oslo/password';
|
import { Argon2id } from 'oslo/password';
|
||||||
import { zod } from 'sveltekit-superforms/adapters';
|
import { zod } from 'sveltekit-superforms/adapters';
|
||||||
import { setError, superValidate } from 'sveltekit-superforms/server';
|
import { setError, superValidate } from 'sveltekit-superforms/server';
|
||||||
|
|
@ -58,7 +58,7 @@ export const actions: Actions = {
|
||||||
let session;
|
let session;
|
||||||
let sessionCookie;
|
let sessionCookie;
|
||||||
const user: Users | undefined = await db.query.users.findFirst({
|
const user: Users | undefined = await db.query.users.findFirst({
|
||||||
where: eq(users.username, form.data.username),
|
where: or(eq(users.username, form.data.username), eq(users.email, form.data.username)),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,29 @@
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="login">
|
<div class="login">
|
||||||
<form method="POST" use:enhance>
|
<h2
|
||||||
<h2
|
|
||||||
class="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0"
|
class="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0"
|
||||||
>
|
>
|
||||||
Log into your account
|
Log into your account
|
||||||
</h2>
|
</h2>
|
||||||
|
{@render usernamePasswordForm()}
|
||||||
|
<p class="px-8 text-center text-sm text-muted-foreground">
|
||||||
|
By clicking continue, you agree to our
|
||||||
|
<a href="/terms" class="underline underline-offset-4 hover:text-primary">
|
||||||
|
Terms of Use
|
||||||
|
</a>
|
||||||
|
and
|
||||||
|
<a href="/privacy" class="underline underline-offset-4 hover:text-primary">
|
||||||
|
Privacy Policy
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet usernamePasswordForm()}
|
||||||
|
<form method="POST" use:enhance>
|
||||||
<Form.Field form={superLoginForm} name="username">
|
<Form.Field form={superLoginForm} name="username">
|
||||||
<Form.Control let:attrs>
|
<Form.Control let:attrs>
|
||||||
<Form.Label for="username">Username</Form.Label>
|
<Form.Label for="username">Username/Email</Form.Label>
|
||||||
<Input {...attrs} autocomplete="username" bind:value={$loginForm.username} />
|
<Input {...attrs} autocomplete="username" bind:value={$loginForm.username} />
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
<Form.FieldErrors />
|
<Form.FieldErrors />
|
||||||
|
|
@ -62,22 +76,13 @@
|
||||||
<Form.FieldErrors />
|
<Form.FieldErrors />
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
<Form.Button>Login</Form.Button>
|
<Form.Button>Login</Form.Button>
|
||||||
<p class="px-8 text-center text-sm text-muted-foreground">
|
|
||||||
By clicking continue, you agree to our
|
|
||||||
<a href="/terms" class="underline underline-offset-4 hover:text-primary">
|
|
||||||
Terms of Use
|
|
||||||
</a>
|
|
||||||
and
|
|
||||||
<a href="/privacy" class="underline underline-offset-4 hover:text-primary">
|
|
||||||
Privacy Policy
|
|
||||||
</a>.
|
|
||||||
</p>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
{/snippet}
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
.login {
|
.login {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { fail, error, type Actions, type Cookies, type RequestEvent } from '@sveltejs/kit';
|
import { fail, error, type Actions } from '@sveltejs/kit';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { Argon2id } from 'oslo/password';
|
import { Argon2id } from 'oslo/password';
|
||||||
import { decodeHex } from 'oslo/encoding';
|
import { decodeHex } from 'oslo/encoding';
|
||||||
|
|
@ -9,7 +9,7 @@ import { redirect } from 'sveltekit-flash-message/server';
|
||||||
import { RateLimiter } from 'sveltekit-rate-limiter/server';
|
import { RateLimiter } from 'sveltekit-rate-limiter/server';
|
||||||
import db from '../../../db';
|
import db from '../../../db';
|
||||||
import { lucia } from '$lib/server/auth';
|
import { lucia } from '$lib/server/auth';
|
||||||
import { 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 } from './$types';
|
||||||
import { notSignedInMessage } from '$lib/flashMessages';
|
import { notSignedInMessage } from '$lib/flashMessages';
|
||||||
|
|
@ -33,7 +33,10 @@ export const load: PageServerLoad = async (event) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!twoFactorDetails || !twoFactorDetails.enabled) {
|
if (!twoFactorDetails || !twoFactorDetails.enabled) {
|
||||||
const message = { type: 'error', message: 'Two factor authentication is not enabled' } as const;
|
const message = {
|
||||||
|
type: 'error',
|
||||||
|
message: 'Two factor authentication is not enabled',
|
||||||
|
} as const;
|
||||||
redirect(302, '/login', message, event);
|
redirect(302, '/login', message, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,7 +54,11 @@ export const load: PageServerLoad = async (event) => {
|
||||||
// Check if two factor started less than TWO_FACTOR_TIMEOUT
|
// Check if two factor started less than TWO_FACTOR_TIMEOUT
|
||||||
const totpElapsed = totpTimeElapsed(twoFactorInitiatedTime);
|
const totpElapsed = totpTimeElapsed(twoFactorInitiatedTime);
|
||||||
if (totpElapsed) {
|
if (totpElapsed) {
|
||||||
console.log('Time elapsed was more than TWO_FACTOR_TIMEOUT', timeElapsed, env.TWO_FACTOR_TIMEOUT);
|
console.log(
|
||||||
|
'Time elapsed was more than TWO_FACTOR_TIMEOUT',
|
||||||
|
totpElapsed,
|
||||||
|
env.TWO_FACTOR_TIMEOUT,
|
||||||
|
);
|
||||||
await lucia.invalidateSession(session!.id!);
|
await lucia.invalidateSession(session!.id!);
|
||||||
const sessionCookie = lucia.createBlankSessionCookie();
|
const sessionCookie = lucia.createBlankSessionCookie();
|
||||||
cookies.set(sessionCookie.name, sessionCookie.value, {
|
cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||||
|
|
@ -67,20 +74,15 @@ export const load: PageServerLoad = async (event) => {
|
||||||
console.log('session', session);
|
console.log('session', session);
|
||||||
console.log('isTwoFactorAuthenticated', isTwoFactorAuthenticated);
|
console.log('isTwoFactorAuthenticated', isTwoFactorAuthenticated);
|
||||||
|
|
||||||
if (
|
if (isTwoFactorAuthenticated && twoFactorDetails?.enabled && twoFactorDetails?.secret !== '') {
|
||||||
isTwoFactorAuthenticated &&
|
|
||||||
twoFactorDetails?.enabled &&
|
|
||||||
twoFactorDetails?.secret !== ''
|
|
||||||
) {
|
|
||||||
const message = { type: 'success', message: 'You are already signed in' } as const;
|
const message = { type: 'success', message: 'You are already signed in' } as const;
|
||||||
throw redirect('/', message, event);
|
throw redirect('/', message, event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = await superValidate(event, zod(totpSchema));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
form,
|
totpForm: await superValidate(event, zod(totpSchema)),
|
||||||
|
recoveryCodeForm: await superValidate(event, zod(recoveryCodeSchema)),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -121,11 +123,7 @@ export const actions: Actions = {
|
||||||
throw redirect(302, '/login', message, event);
|
throw redirect(302, '/login', message, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (isTwoFactorAuthenticated && twoFactorDetails.enabled && twoFactorDetails.secret !== '') {
|
||||||
isTwoFactorAuthenticated &&
|
|
||||||
twoFactorDetails.enabled &&
|
|
||||||
twoFactorDetails.secret !== ''
|
|
||||||
) {
|
|
||||||
const message = { type: 'success', message: 'You are already signed in' } as const;
|
const message = { type: 'success', message: 'You are already signed in' } as const;
|
||||||
throw redirect('/', message, event);
|
throw redirect('/', message, event);
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +149,7 @@ export const actions: Actions = {
|
||||||
});
|
});
|
||||||
} else if (twoFactorSecretPopulated && totpToken) {
|
} else if (twoFactorSecretPopulated && totpToken) {
|
||||||
// Check if two factor started less than TWO_FACTOR_TIMEOUT
|
// Check if two factor started less than TWO_FACTOR_TIMEOUT
|
||||||
const totpElapsed = totpTimeElapsed(twoFactorDetails.initiatedTime);
|
const totpElapsed = totpTimeElapsed(twoFactorDetails.initiatedTime ?? new Date());
|
||||||
if (totpElapsed) {
|
if (totpElapsed) {
|
||||||
await lucia.invalidateSession(session!.id!);
|
await lucia.invalidateSession(session!.id!);
|
||||||
const sessionCookie = lucia.createBlankSessionCookie();
|
const sessionCookie = lucia.createBlankSessionCookie();
|
||||||
|
|
@ -178,6 +176,7 @@ export const actions: Actions = {
|
||||||
const usedRecoveryCode = await checkRecoveryCode(totpToken, dbUser.id);
|
const usedRecoveryCode = await checkRecoveryCode(totpToken, dbUser.id);
|
||||||
if (!usedRecoveryCode) {
|
if (!usedRecoveryCode) {
|
||||||
console.log('invalid TOTP code');
|
console.log('invalid TOTP code');
|
||||||
|
form.data.totpToken = '';
|
||||||
return setError(form, 'totpToken', 'Invalid code.');
|
return setError(form, 'totpToken', 'Invalid code.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,17 @@
|
||||||
import { superForm } from 'sveltekit-superforms/client';
|
import { superForm } from 'sveltekit-superforms/client';
|
||||||
import * as flashModule from 'sveltekit-flash-message/client';
|
import * as flashModule from 'sveltekit-flash-message/client';
|
||||||
import { AlertCircle } from "lucide-svelte";
|
import { AlertCircle } from "lucide-svelte";
|
||||||
import { signInSchema, totpSchema } from '$lib/validations/auth';
|
import { recoveryCodeSchema, totpSchema } from '$lib/validations/auth';
|
||||||
import * as Form from '$lib/components/ui/form';
|
import * as Form from '$lib/components/ui/form';
|
||||||
import { Label } from '$components/ui/label';
|
import { Label } from '$components/ui/label';
|
||||||
import { Input } from '$components/ui/input';
|
import { Input } from '$components/ui/input';
|
||||||
import { Button } from '$components/ui/button';
|
import { Button } from '$components/ui/button';
|
||||||
import * as Alert from "$components/ui/alert";
|
import * as Alert from "$components/ui/alert";
|
||||||
import { boredState } from '$lib/stores/boredState.js';
|
|
||||||
import PinInput from '$components/pin-input.svelte';
|
import PinInput from '$components/pin-input.svelte';
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
const superTotpForm = superForm(data.form, {
|
const superTotpForm = superForm(data.totpForm, {
|
||||||
onSubmit: () => boredState.update((n) => ({ ...n, loading: true })),
|
|
||||||
onResult: () => boredState.update((n) => ({ ...n, loading: false })),
|
|
||||||
flashMessage: {
|
flashMessage: {
|
||||||
module: flashModule,
|
module: flashModule,
|
||||||
onError: ({ result, flashMessage }) => {
|
onError: ({ result, flashMessage }) => {
|
||||||
|
|
@ -34,59 +31,78 @@
|
||||||
delayMs: 0,
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
let showRecoveryCode = $state(false);
|
let showRecoveryCode = $state(false);
|
||||||
|
|
||||||
const { form: totpForm, enhance } = superTotpForm;
|
const { form: totpFormData, enhance: totpEnhance } = superTotpForm;
|
||||||
|
const { form: recoveryCodeFormData, enhance: recoveryCodeEnhance } = superRecoveryCodeForm;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Bored Game | Login</title>
|
<title>Bored Game | Login</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="login">
|
<div class="totp">
|
||||||
<form method="POST" use:enhance>
|
<h2
|
||||||
<h2
|
class="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0"
|
||||||
class="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0"
|
>
|
||||||
>
|
Please enter your {showRecoveryCode ? 'recovery code' : 'TOTP code'}
|
||||||
Please enter your {showRecoveryCode ? 'recovery code' : 'TOTP code'}
|
</h2>
|
||||||
</h2>
|
{#if !showRecoveryCode}
|
||||||
<Form.Field form={superTotpForm} name="totpToken">
|
{@render totpForm()}
|
||||||
|
<Button variant="link" class="text-secondary-foreground" on:click={() => showRecoveryCode = true}>Show Recovery Code</Button>
|
||||||
|
{:else}
|
||||||
|
{@render recoveryCodeForm()}
|
||||||
|
<Button variant="link" class="text-secondary-foreground" on:click={() => showRecoveryCode = false}>Show TOTP Code</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet totpForm()}
|
||||||
|
<form method="POST" use:totpEnhance>
|
||||||
|
<Form.Field form={totpFormData} name="totpToken">
|
||||||
<Form.Control let:attrs>
|
<Form.Control let:attrs>
|
||||||
{#if showRecoveryCode}
|
<Form.Label for="totpToken">TOTP Code</Form.Label>
|
||||||
<Form.Label for="totpToken">Recovery Code</Form.Label>
|
<PinInput {...attrs} bind:value={$totpFormData.totpToken} />
|
||||||
<Input {...attrs} autocomplete="one-time-code" bind:value={$totpForm.totpToken} />
|
|
||||||
{:else}
|
|
||||||
<Form.Label for="totpToken">TOTP Code</Form.Label>
|
|
||||||
<PinInput {...attrs} bind:value={$totpForm.totpToken} />
|
|
||||||
{/if}
|
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
<Form.FieldErrors />
|
<Form.FieldErrors />
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
<Form.Button>Submit</Form.Button>
|
<Form.Button>Submit</Form.Button>
|
||||||
</form>
|
</form>
|
||||||
{#if !showRecoveryCode}
|
{/snippet}
|
||||||
<Button variant="link" class="text-secondary-foreground" on:click={() => showRecoveryCode = true}>Show Recovery Code</Button>
|
|
||||||
{:else}
|
{#snippet recoveryCodeForm()}
|
||||||
<Button variant="link" class="text-secondary-foreground" on:click={() => showRecoveryCode = false}>Show TOTP Code</Button>
|
<form method="POST" use:recoveryCodeEnhance>
|
||||||
{/if}
|
<Form.Field form={recoveryCodeFormData} name="recoveryCode">
|
||||||
</div>
|
<Form.Control let:attrs>
|
||||||
|
<Form.Label for="totpToken">Recovery Code</Form.Label>
|
||||||
|
<PinInput {...attrs} bind:value={$recoveryCodeFormData.recoveryCode} inputCount={10} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Button>Submit</Form.Button>
|
||||||
|
</form>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
.loading {
|
.totp {
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
z-index: 101;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.login {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue