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, "when": 1711868447607,
"tag": "0009_gray_carlie_cooper", "tag": "0009_gray_carlie_cooper",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "5",
"when": 1712271520175,
"tag": "0010_wakeful_metal_master",
"breakpoints": true
} }
] ]
} }

View file

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

View file

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

View file

@ -8,22 +8,49 @@
import { addTwoFactorSchema } from '$lib/validations/account'; import { addTwoFactorSchema } from '$lib/validations/account';
export let data; export let data;
export let form;
console.log('recovery codes', form.recoveryCodes) const { qrCode, twoFactorEnabled, recoveryCodes } = data;
const { qrCode } = data; const form = superForm(data.form, {
const twoFactorForm = superForm(data.form, {
taintedMessage: null, taintedMessage: null,
validators: zodClient(addTwoFactorSchema), validators: zodClient(addTwoFactorSchema),
delayMs: 500, delayMs: 500,
multipleSubmits: 'prevent', multipleSubmits: 'prevent',
}); });
const { form: formData, enhance } = twoFactorForm; const { form: formData, enhance } = form;
</script> </script>
<h1>Two-Factor Authentication</h1> <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}