Separate page for two factory recovery codes.

This commit is contained in:
Bradley Shellnut 2024-04-06 23:59:59 -07:00
parent 4880b87922
commit 826d06113d
7 changed files with 103 additions and 107 deletions

View file

@ -30,7 +30,7 @@
"@sveltejs/kit": "^2.5.5",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@types/cookie": "^0.6.0",
"@types/node": "^20.12.4",
"@types/node": "^20.12.5",
"@types/pg": "^8.11.4",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
@ -102,7 +102,7 @@
"loader": "^2.1.1",
"lucia": "3.1.1",
"lucide-svelte": "^0.358.0",
"open-props": "^1.7.0",
"open-props": "^1.7.2",
"oslo": "^1.2.0",
"pg": "^8.11.5",
"postgres": "^3.4.4",

View file

@ -87,8 +87,8 @@ dependencies:
specifier: ^0.358.0
version: 0.358.0(svelte@4.2.12)
open-props:
specifier: ^1.7.0
version: 1.7.0
specifier: ^1.7.2
version: 1.7.2
oslo:
specifier: ^1.2.0
version: 1.2.0
@ -152,8 +152,8 @@ devDependencies:
specifier: ^0.6.0
version: 0.6.0
'@types/node':
specifier: ^20.12.4
version: 20.12.4
specifier: ^20.12.5
version: 20.12.5
'@types/pg':
specifier: ^8.11.4
version: 8.11.4
@ -246,7 +246,7 @@ devDependencies:
version: 3.4.3(ts-node@10.9.2)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@20.12.4)(typescript@5.4.4)
version: 10.9.2(@types/node@20.12.5)(typescript@5.4.4)
tslib:
specifier: ^2.6.1
version: 2.6.2
@ -258,10 +258,10 @@ devDependencies:
version: 5.4.4
vite:
specifier: ^5.2.8
version: 5.2.8(@types/node@20.12.4)(sass@1.74.1)
version: 5.2.8(@types/node@20.12.5)(sass@1.74.1)
vitest:
specifier: ^1.4.0
version: 1.4.0(@types/node@20.12.4)(sass@1.74.1)
version: 1.4.0(@types/node@20.12.5)(sass@1.74.1)
zod:
specifier: ^3.22.4
version: 3.22.4
@ -3286,7 +3286,7 @@ packages:
sirv: 2.0.4
svelte: 4.2.12
tiny-glob: 0.2.9
vite: 5.2.8(@types/node@20.12.4)(sass@1.74.1)
vite: 5.2.8(@types/node@20.12.5)(sass@1.74.1)
/@sveltejs/vite-plugin-svelte-inspector@2.0.0(@sveltejs/vite-plugin-svelte@3.0.2)(svelte@4.2.12)(vite@5.2.8):
resolution: {integrity: sha512-gjr9ZFg1BSlIpfZ4PRewigrvYmHWbDrq2uvvPB1AmTWKuM+dI1JXQSUu2pIrYLb/QncyiIGkFDFKTwJ0XqQZZg==}
@ -3299,7 +3299,7 @@ packages:
'@sveltejs/vite-plugin-svelte': 3.0.2(svelte@4.2.12)(vite@5.2.8)
debug: 4.3.4
svelte: 4.2.12
vite: 5.2.8(@types/node@20.12.4)(sass@1.74.1)
vite: 5.2.8(@types/node@20.12.5)(sass@1.74.1)
transitivePeerDependencies:
- supports-color
@ -3317,7 +3317,7 @@ packages:
magic-string: 0.30.5
svelte: 4.2.12
svelte-hmr: 0.15.3(svelte@4.2.12)
vite: 5.2.8(@types/node@20.12.4)(sass@1.74.1)
vite: 5.2.8(@types/node@20.12.5)(sass@1.74.1)
vitefu: 0.2.5(vite@5.2.8)
transitivePeerDependencies:
- supports-color
@ -3360,22 +3360,22 @@ packages:
/@types/json-schema@7.0.15:
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
/@types/node@20.12.4:
resolution: {integrity: sha512-E+Fa9z3wSQpzgYQdYmme5X3OTuejnnTx88A6p6vkkJosR3KBz+HpE3kqNm98VE6cfLFcISx7zW7MsJkH6KwbTw==}
/@types/node@20.12.5:
resolution: {integrity: sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==}
dependencies:
undici-types: 5.26.5
/@types/pg@8.11.4:
resolution: {integrity: sha512-yw3Bwbda6vO+NvI1Ue/YKOwtl31AYvvd/e73O3V4ZkNzuGpTDndLSyc0dQRB2xrQqDePd20pEGIfqSp/GH3pRw==}
dependencies:
'@types/node': 20.12.4
'@types/node': 20.12.5
pg-protocol: 1.6.0
pg-types: 4.0.2
/@types/pg@8.6.6:
resolution: {integrity: sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw==}
dependencies:
'@types/node': 20.12.4
'@types/node': 20.12.5
pg-protocol: 1.6.0
pg-types: 2.2.0
dev: false
@ -5851,8 +5851,8 @@ packages:
mimic-fn: 4.0.0
dev: true
/open-props@1.7.0:
resolution: {integrity: sha512-exvA+8HSxD5qihtBnaDQ1uSKrZV/hfM4/K6UCY7LMzwiMKwejshuNPVpM7exoe74wluOq1NdWYmoSfFxGW9iyw==}
/open-props@1.7.2:
resolution: {integrity: sha512-RheKypVzZBCSZ6c5iJaFWG0OBqdtql3eRFXRYrSNLh6vGzU8NSAHuq9iJPj++DrpPGs1pqlRa2BelwwBHjX3Xg==}
dev: false
/optionator@0.9.3:
@ -6334,7 +6334,7 @@ packages:
dependencies:
lilconfig: 2.1.0
postcss: 8.4.38
ts-node: 10.9.2(@types/node@20.12.4)(typescript@5.4.4)
ts-node: 10.9.2(@types/node@20.12.5)(typescript@5.4.4)
yaml: 1.10.2
dev: true
@ -6352,7 +6352,7 @@ packages:
dependencies:
lilconfig: 3.0.0
postcss: 8.4.38
ts-node: 10.9.2(@types/node@20.12.4)(typescript@5.4.4)
ts-node: 10.9.2(@types/node@20.12.5)(typescript@5.4.4)
yaml: 2.3.4
/postcss-load-config@5.0.3(postcss@8.4.38):
@ -7365,23 +7365,6 @@ packages:
peerDependencies:
'@sveltejs/kit': 1.x || 2.x
svelte: 3.x || 4.x || >=5.0.0-next.51
peerDependenciesMeta:
'@sinclair/typebox':
optional: true
'@vinejs/vine':
optional: true
arktype:
optional: true
joi:
optional: true
superstruct:
optional: true
valibot:
optional: true
yup:
optional: true
zod:
optional: true
dependencies:
'@sveltejs/kit': 2.5.5(@sveltejs/vite-plugin-svelte@3.0.2)(svelte@4.2.12)(vite@5.2.8)
devalue: 4.3.2
@ -7574,7 +7557,7 @@ packages:
/ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
/ts-node@10.9.2(@types/node@20.12.4)(typescript@5.4.4):
/ts-node@10.9.2(@types/node@20.12.5)(typescript@5.4.4):
resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==}
hasBin: true
peerDependencies:
@ -7593,7 +7576,7 @@ packages:
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 20.12.4
'@types/node': 20.12.5
acorn: 8.11.2
acorn-walk: 8.3.0
arg: 4.1.3
@ -7742,7 +7725,7 @@ packages:
- rollup
dev: true
/vite-node@1.4.0(@types/node@20.12.4)(sass@1.74.1):
/vite-node@1.4.0(@types/node@20.12.5)(sass@1.74.1):
resolution: {integrity: sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
@ -7751,7 +7734,7 @@ packages:
debug: 4.3.4
pathe: 1.1.2
picocolors: 1.0.0
vite: 5.2.8(@types/node@20.12.4)(sass@1.74.1)
vite: 5.2.8(@types/node@20.12.5)(sass@1.74.1)
transitivePeerDependencies:
- '@types/node'
- less
@ -7763,7 +7746,7 @@ packages:
- terser
dev: true
/vite@5.2.8(@types/node@20.12.4)(sass@1.74.1):
/vite@5.2.8(@types/node@20.12.5)(sass@1.74.1):
resolution: {integrity: sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
@ -7791,7 +7774,7 @@ packages:
terser:
optional: true
dependencies:
'@types/node': 20.12.4
'@types/node': 20.12.5
esbuild: 0.20.2
postcss: 8.4.38
rollup: 4.13.0
@ -7807,9 +7790,9 @@ packages:
vite:
optional: true
dependencies:
vite: 5.2.8(@types/node@20.12.4)(sass@1.74.1)
vite: 5.2.8(@types/node@20.12.5)(sass@1.74.1)
/vitest@1.4.0(@types/node@20.12.4)(sass@1.74.1):
/vitest@1.4.0(@types/node@20.12.5)(sass@1.74.1):
resolution: {integrity: sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
@ -7834,7 +7817,7 @@ packages:
jsdom:
optional: true
dependencies:
'@types/node': 20.12.4
'@types/node': 20.12.5
'@vitest/expect': 1.4.0
'@vitest/runner': 1.4.0
'@vitest/snapshot': 1.4.0
@ -7852,8 +7835,8 @@ packages:
strip-literal: 2.0.0
tinybench: 2.6.0
tinypool: 0.8.2
vite: 5.2.8(@types/node@20.12.4)(sass@1.74.1)
vite-node: 1.4.0(@types/node@20.12.4)(sass@1.74.1)
vite: 5.2.8(@types/node@20.12.5)(sass@1.74.1)
vite-node: 1.4.0(@types/node@20.12.5)(sass@1.74.1)
why-is-node-running: 2.2.2
transitivePeerDependencies:
- less

View file

@ -37,7 +37,7 @@ export const load: PageServerLoad = async (event) => {
return {
profileForm,
emailForm,
hasSetupTwoFactor: !!dbUser?.two_factor_secret,
hasSetupTwoFactor: !!dbUser?.two_factor_enabled,
};
};

View file

@ -1,19 +1,18 @@
import { type Actions, fail } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { createId as cuid2 } from '@paralleldrive/cuid2';
import { encodeHex, decodeHex } from 'oslo/encoding';
import { Argon2id } from 'oslo/password';
import { createTOTPKeyURI, TOTPController } from 'oslo/otp';
import { HMAC } from 'oslo/crypto';
import QRCode from 'qrcode';
import { zod } from 'sveltekit-superforms/adapters';
import { message, setError, superValidate } from 'sveltekit-superforms/server';
import { setError, superValidate } from 'sveltekit-superforms/server';
import { redirect } from 'sveltekit-flash-message/server';
import type { PageServerLoad } from '../../$types';
import { addTwoFactorSchema } from '$lib/validations/account';
import { notSignedInMessage } from '$lib/flashMessages';
import db from '$lib/drizzle';
import { recovery_codes, users } from '../../../../../../schema';
import { users } from '../../../../../../schema';
export const load: PageServerLoad = async (event) => {
const form = await superValidate(event, zod(addTwoFactorSchema));
@ -23,41 +22,6 @@ export const load: PageServerLoad = async (event) => {
redirect(302, '/login', notSignedInMessage, event);
}
const dbUser = await db.query.users.findFirst({
where: eq(users.id, user.id),
});
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',
} as const;
throw redirect('/profile', message, event);
}
const twoFactorSecret = await new HMAC('SHA-1').generateKey();
await db
.update(users)
@ -71,8 +35,6 @@ 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 = await QRCode.toDataURL(totpUri);
console.log('QR Code: ', qrCode);
form.data = {
current_password: '',
@ -83,7 +45,7 @@ export const load: PageServerLoad = async (event) => {
twoFactorEnabled: false,
recoveryCodes: [],
totpUri,
qrCode,
qrCode: await QRCode.toDataURL(totpUri),
};
};
@ -154,15 +116,6 @@ export const actions: Actions = {
await db.update(users).set({ two_factor_enabled: true }).where(eq(users.id, user.id));
form.data.current_password = '';
form.data.two_factor_code = '';
return {
form,
twoFactorEnabled: true,
};
redirect(302, '/profile/security/two-factor/recovery-codes');
},
};
function generateRecoveryCodes() {
return Array.from({ length: 5 }, () => cuid2());
}

View file

@ -18,6 +18,8 @@
multipleSubmits: 'prevent',
});
console.log('Two Factor: ', twoFactorEnabled, recoveryCodes);
const { form: formData, enhance } = form;
</script>
@ -25,16 +27,10 @@
{#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 method="POST" use:enhance data-sveltekit-replacestate>
<Form.Field {form} name="two_factor_code">
<Form.Control let:attrs>
<Form.Label for="code">Enter Code</Form.Label>

View file

@ -0,0 +1,46 @@
import db from "$lib/drizzle";
import {eq} from "drizzle-orm";
import {Argon2id} from "oslo/password";
import {alphabet, generateRandomString} from "oslo/crypto";
import {redirect} from "sveltekit-flash-message/server";
import {notSignedInMessage} from "$lib/flashMessages";
import type { PageServerLoad } from '../../../$types';
import {recovery_codes, users} from "../../../../../../../schema";
export const load: PageServerLoad = async (event) => {
const user = event.locals.user;
if (!user) {
redirect(302, '/login', notSignedInMessage, event);
}
const dbUser = await db.query.users.findFirst({
where: eq(users.id, user.id),
});
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 = Array.from({length: 5}, () => generateRandomString(10, alphabet('A-Z', '0-9')));
if (recoveryCodes) {
for (const code of recoveryCodes) {
await db.insert(recovery_codes).values({
userId: user.id,
code: await new Argon2id().hash(code),
});
}
}
return {
recoveryCodes,
};
}
return {
recoveryCodes: [],
}
} else {
redirect(302, '/profile', { message: 'Two-Factor Authentication is not enabled', type: 'error' }, event);
}
}

View file

@ -0,0 +1,18 @@
<script lang="ts">
export let data;
const { recoveryCodes } = data;
</script>
<h2>Two-Factor Authentication is <span class="text-green-500">enabled</span></h2>
{#if recoveryCodes && recoveryCodes.length > 0}
{#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>You have already viewed your recovery codes.</h2>
<p>If you wish to generate new codes, please disable and then re-enable two-factor authentication.</p>
{/if}