feat: improve account security

Support TOTP 2fa, allow for changing password, add session management
and account removal.
This commit is contained in:
Zoe
2026-10-08 12:43:22 -05:00
parent ff33e9c573
commit 4bba529ba8
27 changed files with 4874 additions and 468 deletions
+162 -5
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import CodeInput from '~/components/CodeInput.vue';
import { deriveKey } from '~/utils/crypto';
const { client: authClient, signIn, session } = useAuth();
const { client: authClient, signIn, verifyTwoFactor, session } = useAuth();
definePageMeta({
layout: 'auth',
@@ -21,6 +22,27 @@ const form = reactive({
});
const loading = ref(false);
// second step, only shown for accounts with two-factor authentication
const step = ref<'credentials' | 'twoFactor'>('credentials');
const twoFactor = reactive({
code: '',
useBackupCode: false,
trustDevice: false,
error: '',
});
const codeInputEl = ref<InstanceType<typeof CodeInput> | null>(null);
const backupCodeInputEl = ref<HTMLInputElement | null>(null);
const focusCodeInput = async () => {
await nextTick();
if (twoFactor.useBackupCode) {
backupCodeInputEl.value?.focus();
} else {
codeInputEl.value?.focus();
}
};
const emailInputEl = ref<HTMLInputElement | null>(null);
const passwordInputEl = ref<HTMLInputElement | null>(null);
@@ -97,11 +119,94 @@ const submit = async () => {
return;
}
const key = await deriveKey(form.password, res.data.user.id);
if (res.data.twoFactorRequired) {
twoFactor.code = '';
twoFactor.error = '';
twoFactor.useBackupCode = false;
step.value = 'twoFactor';
await focusCodeInput();
return;
}
return finishSignIn(res.data.user.id);
};
// The password is still in memory at this point, which is the only time we
// can derive the key that encrypts the user's API keys.
const finishSignIn = async (userId: string) => {
const key = await deriveKey(form.password, userId);
localStorage.setItem('encryptionKey', JSON.stringify(key));
return navigateTo(to ?? '/');
};
const backToCredentials = (error = '') => {
step.value = 'credentials';
twoFactor.code = '';
twoFactor.error = '';
if (error) {
passwordInputEl.value!.setCustomValidity(error);
passwordInputEl.value!.reportValidity();
}
};
const toggleBackupCode = async () => {
twoFactor.useBackupCode = !twoFactor.useBackupCode;
twoFactor.code = '';
twoFactor.error = '';
await focusCodeInput();
};
const submitTwoFactor = async () => {
const code = twoFactor.code.trim();
if (code === '' || loading.value) return;
loading.value = true;
twoFactor.error = '';
const res = await verifyTwoFactor(code, {
backupCode: twoFactor.useBackupCode,
trustDevice: twoFactor.trustDevice,
});
if (!res.ok) {
loading.value = false;
if (res.error.data?.status === 429) {
twoFactor.error = 'Too many attempts. Wait a few seconds and try again.';
return;
}
switch (res.error.data?.code) {
case 'INVALID_CODE':
twoFactor.error = 'That code is not right. Check your authenticator app and try again.';
break;
case 'INVALID_BACKUP_CODE':
twoFactor.error = 'That backup code is not valid, or it was already used.';
break;
case 'ACCOUNT_TEMPORARILY_LOCKED':
twoFactor.error = 'Too many wrong codes. Two-factor sign-in is locked for a while, try again later.';
break;
case 'INVALID_TWO_FACTOR_COOKIE':
// the 10 minute window to enter a code ran out
backToCredentials('Sign in timed out. Enter your password again.');
break;
default:
twoFactor.error = 'Something went wrong. Try again.';
break;
}
twoFactor.code = '';
await focusCodeInput();
return;
}
await finishSignIn(res.data.user.id);
loading.value = false;
};
</script>
<template>
@@ -117,11 +222,17 @@ const submit = async () => {
<div class="z-10 max-w-sm w-full">
<div class="flex flex-col items-center mb-4 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
<p class="text-[var(--text-secondary)]">Sign in to your account</p>
<p class="text-[var(--text-secondary)]">
{{ step === 'credentials'
? 'Sign in to your account'
: twoFactor.useBackupCode
? 'Enter one of your backup codes'
: 'Enter the code from your authenticator app' }}
</p>
</div>
<div class="rounded-2xl">
<form class="flex flex-col gap-2" @submit.prevent="submit">
<form v-show="step === 'credentials'" class="flex flex-col gap-2" @submit.prevent="submit">
<div class="flex flex-col gap-1.5">
<label for="email"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label>
@@ -144,9 +255,55 @@ const submit = async () => {
<span v-else>Sign In</span>
</button>
</form>
<form v-if="step === 'twoFactor'" class="flex flex-col gap-3" @submit.prevent="submitTwoFactor">
<div v-if="twoFactor.useBackupCode" class="flex flex-col gap-1.5">
<label for="two-factor-code"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
Backup code
</label>
<input required ref="backupCodeInputEl" type="text" autocomplete="off" autocapitalize="off"
spellcheck="false" id="two-factor-code" v-model="twoFactor.code"
placeholder="xxxxx-xxxxx"
class="font-mono focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<fieldset v-else>
<legend
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
6-digit code
</legend>
<CodeInput ref="codeInputEl" v-model="twoFactor.code" large :disabled="loading"
@complete="submitTwoFactor" />
</fieldset>
<p v-if="twoFactor.error" role="alert" class="text-sm text-red-400 ml-1">
{{ twoFactor.error }}
</p>
<label class="flex items-center gap-2.5 text-sm text-[var(--text-secondary)] cursor-pointer">
<input type="checkbox" v-model="twoFactor.trustDevice"
class="w-4 h-4 p-0 m-0 accent-[var(--color-accent)]" />
<span>Trust this browser for 30 days</span>
</label>
<button class="accent w-full h-11 flex items-center justify-center font-semibold" type="submit">
<span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
<span v-else>Verify</span>
</button>
<div class="flex flex-col items-center gap-2 mt-2 text-sm text-[var(--text-secondary)]">
<button type="button" @click="toggleBackupCode"
class="text-[var(--color-accent)] font-medium @hover:underline">
{{ twoFactor.useBackupCode ? 'Use your authenticator app instead' : 'Use a backup code instead' }}
</button>
<button type="button" @click="backToCredentials()" class="@hover:underline">
Back to log in
</button>
</div>
</form>
</div>
<p v-if="!config.public.disableSignup" class="text-sm text-center mt-3 text-[var(--text-secondary)]">
<p v-if="!config.public.disableSignup && step === 'credentials'" class="text-sm text-center mt-3 text-[var(--text-secondary)]">
New to Veridian?
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
class="text-[var(--color-accent)] font-medium @hover:underline">