Writing code to send recovery to it's own page.

This commit is contained in:
Bradley Shellnut 2024-04-05 16:10:12 -07:00
parent 564c58a2c6
commit 4880b87922
8 changed files with 1947 additions and 262 deletions

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS "recovery_codes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"code" text NOT NULL,
"used" boolean DEFAULT false,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "recovery_codes" ADD CONSTRAINT "recovery_codes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,13 @@
"when": 1711868447607,
"tag": "0009_gray_carlie_cooper",
"breakpoints": true
},
{
"idx": 10,
"version": "5",
"when": 1712271520175,
"tag": "0010_wakeful_metal_master",
"breakpoints": true
}
]
}

View file

@ -22,15 +22,15 @@
},
"devDependencies": {
"@melt-ui/pp": "^0.3.0",
"@melt-ui/svelte": "^0.76.2",
"@playwright/test": "^1.42.1",
"@melt-ui/svelte": "^0.76.3",
"@playwright/test": "^1.43.0",
"@resvg/resvg-js": "^2.6.2",
"@sveltejs/adapter-auto": "^3.2.0",
"@sveltejs/enhanced-img": "^0.1.9",
"@sveltejs/kit": "^2.5.5",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@types/cookie": "^0.6.0",
"@types/node": "^20.12.2",
"@types/node": "^20.12.4",
"@types/pg": "^8.11.4",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
@ -45,14 +45,14 @@
"postcss": "^8.4.38",
"postcss-import": "^16.1.0",
"postcss-load-config": "^5.0.3",
"postcss-preset-env": "^9.5.2",
"postcss-preset-env": "^9.5.4",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.2",
"sass": "^1.72.0",
"sass": "^1.74.1",
"satori": "^0.10.13",
"satori-html": "^0.3.2",
"svelte": "^4.2.12",
"svelte-check": "^3.6.8",
"svelte-check": "^3.6.9",
"svelte-headless-table": "^0.18.2",
"svelte-meta-tags": "^3.1.1",
"svelte-preprocess": "^5.1.3",
@ -63,9 +63,9 @@
"tailwindcss": "^3.4.3",
"ts-node": "^10.9.2",
"tslib": "^2.6.1",
"tsx": "^4.7.1",
"typescript": "^5.4.3",
"vite": "^5.2.7",
"tsx": "^4.7.2",
"typescript": "^5.4.4",
"vite": "^5.2.8",
"vitest": "^1.4.0",
"zod": "^3.22.4"
},
@ -92,7 +92,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cookie": "^0.6.0",
"drizzle-orm": "^0.30.6",
"drizzle-orm": "^0.30.7",
"feather-icons": "^4.29.1",
"formsnap": "^0.5.1",
"html-entities": "^2.5.2",
@ -102,9 +102,9 @@
"loader": "^2.1.1",
"lucia": "3.1.1",
"lucide-svelte": "^0.358.0",
"open-props": "^1.6.21",
"open-props": "^1.7.0",
"oslo": "^1.2.0",
"pg": "^8.11.4",
"pg": "^8.11.5",
"postgres": "^3.4.4",
"qrcode": "^1.5.3",
"radix-svelte": "^0.9.0",

File diff suppressed because it is too large Load diff

6
src/app.d.ts vendored
View file

@ -6,7 +6,11 @@
declare global {
namespace App {
interface PageData {
flash?: { type: 'success' | 'error' | 'info'; message: string; data: any };
flash?: {
type: 'success' | 'error' | 'info';
message: string;
data?: Record<string, unknown>;
};
}
interface Locals {
auth: import('lucia').AuthRequest;

View file

@ -28,6 +28,29 @@ export const load: PageServerLoad = async (event) => {
});
if (dbUser?.two_factor_enabled) {
const recoveryCodes = await db.query.recovery_codes.findMany({
where: eq(recovery_codes.userId, user.id),
});
if (recoveryCodes.length === 0) {
const recoveryCodes = generateRecoveryCodes();
if (recoveryCodes) {
for (const code of recoveryCodes) {
await db.insert(recovery_codes).values({
userId: user.id,
code: await new Argon2id().hash(code),
});
}
}
return {
form,
twoFactorEnabled: true,
recoveryCodes,
totpUri: '',
qrCode: '',
};
}
const message = {
type: 'info',
message: 'Two-Factor Authentication is already enabled',
@ -48,7 +71,8 @@ export const load: PageServerLoad = async (event) => {
const accountName = user.email || user.username;
// pass the website's name and the user identifier (e.g. email, username)
const totpUri = createTOTPKeyURI(issuer, accountName, twoFactorSecret);
const qrCode = QRCode.toDataURL(totpUri);
const qrCode = await QRCode.toDataURL(totpUri);
console.log('QR Code: ', qrCode);
form.data = {
current_password: '',
@ -57,6 +81,7 @@ export const load: PageServerLoad = async (event) => {
return {
form,
twoFactorEnabled: false,
recoveryCodes: [],
totpUri,
qrCode,
};
@ -129,19 +154,12 @@ export const actions: Actions = {
await db.update(users).set({ two_factor_enabled: true }).where(eq(users.id, user.id));
const recoveryCodes = generateRecoveryCodes();
if (recoveryCodes) {
for (const code of recoveryCodes) {
await db.insert(recovery_codes).values({
userId: user.id,
code: await new Argon2id().hash(code),
});
}
}
form.data.current_password = '';
form.data.two_factor_code = '';
return { recoveryCodes };
return {
form,
twoFactorEnabled: true,
};
},
};

View file

@ -8,22 +8,49 @@
import { addTwoFactorSchema } from '$lib/validations/account';
export let data;
export let form;
console.log('recovery codes', form.recoveryCodes)
const { qrCode, twoFactorEnabled, recoveryCodes } = data;
const { qrCode } = data;
const twoFactorForm = superForm(data.form, {
const form = superForm(data.form, {
taintedMessage: null,
validators: zodClient(addTwoFactorSchema),
delayMs: 500,
multipleSubmits: 'prevent',
});
const { form: formData, enhance } = twoFactorForm;
const { form: formData, enhance } = form;
</script>
<h1>Two-Factor Authentication</h1>
<img src={qrCode} alt="QR Code" />
{#if twoFactorEnabled}
<h2>Two-Factor Authentication is <span class="text-green-500">enabled</span></h2>
{#if recoveryCodes.length > 0}
Please copy the recovery codes below as they will not be shown again.
{#each recoveryCodes as code}
<p>{code}</p>
{/each}
{/if}
{:else}
<h2>Please scan the following QR Code</h2>
<img src={qrCode} alt="QR Code" />
<form method="POST" use:enhance>
<Form.Field {form} name="two_factor_code">
<Form.Control let:attrs>
<Form.Label for="code">Enter Code</Form.Label>
<Input {...attrs} bind:value={$formData.two_factor_code} />
</Form.Control>
<Form.Description>This is the code from your authenticator app.</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="current_password">
<Form.Control let:attrs>
<Form.Label for="password">Enter Password</Form.Label>
<Input type="password" {...attrs} bind:value={$formData.current_password} />
</Form.Control>
<Form.Description>Please enter your current password.</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Button>Submit</Form.Button>
</form>
{/if}