feat(auth): harden authentication and add configurable two-factor support
This commit is contained in:
@@ -26,7 +26,7 @@ function submit() {
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<UIcon name="i-lucide-shield-check" class="text-primary size-10" />
|
||||
<UIcon name="i-lucide-shield-check" class="mx-auto block size-10 text-primary" />
|
||||
<h1 class="text-xl font-semibold">
|
||||
Two-factor authentication
|
||||
</h1>
|
||||
|
||||
222
resources/js/Pages/Auth/TwoFactorSetup.vue
Normal file
222
resources/js/Pages/Auth/TwoFactorSetup.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3'
|
||||
import { computed, ref } from 'vue'
|
||||
import RecoveryCodesPanel from '@/components/auth/RecoveryCodesPanel.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue'
|
||||
|
||||
interface TwoFactorState {
|
||||
available: boolean
|
||||
enabled: boolean
|
||||
pending: boolean
|
||||
requiresPassword: boolean
|
||||
qrCodeDataUri: string | null
|
||||
secretKey: string | null
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
twoFactor: TwoFactorState
|
||||
}>()
|
||||
|
||||
const { flash } = useAuth()
|
||||
const enableForm = useForm({ password: '' })
|
||||
const confirmForm = useForm({ code: '' })
|
||||
const logoutForm = useForm({})
|
||||
const recoveryCodes = computed(() => flash.value.recoveryCodes ?? [])
|
||||
const manualKeyCopied = ref(false)
|
||||
|
||||
function enable() {
|
||||
enableForm.post('/two-factor-setup', {
|
||||
onFinish: () => enableForm.reset(),
|
||||
})
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
confirmForm.post('/two-factor-setup/confirm', {
|
||||
onFinish: () => confirmForm.reset(),
|
||||
})
|
||||
}
|
||||
|
||||
function logout() {
|
||||
logoutForm.post('/logout')
|
||||
}
|
||||
|
||||
async function copyManualKey(secretKey: string | null) {
|
||||
if (!secretKey)
|
||||
return
|
||||
|
||||
await navigator.clipboard.writeText(secretKey)
|
||||
manualKeyCopied.value = true
|
||||
|
||||
window.setTimeout(() => {
|
||||
manualKeyCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2 text-center">
|
||||
<span class="mx-auto flex size-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<UIcon
|
||||
:name="twoFactor.enabled ? 'i-lucide-shield-check' : 'i-lucide-shield-keyhole'"
|
||||
class="size-6 text-primary"
|
||||
/>
|
||||
</span>
|
||||
<h1 class="text-xl font-semibold">
|
||||
{{ twoFactor.enabled ? 'Setup complete' : 'Secure your account' }}
|
||||
</h1>
|
||||
<p class="text-muted text-sm">
|
||||
{{ twoFactor.enabled
|
||||
? 'Two-factor authentication is ready. Save your recovery codes before continuing.'
|
||||
: 'Two-factor authentication is required before you can continue.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="flash.error"
|
||||
color="error"
|
||||
icon="i-lucide-alert-circle"
|
||||
:title="flash.error"
|
||||
/>
|
||||
|
||||
<div v-if="twoFactor.enabled" class="space-y-5">
|
||||
<UAlert
|
||||
color="success"
|
||||
variant="subtle"
|
||||
icon="i-lucide-check-circle"
|
||||
title="Your authenticator has been verified."
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p class="mb-3 text-sm font-medium">
|
||||
Recovery codes
|
||||
</p>
|
||||
<RecoveryCodesPanel :codes="recoveryCodes" />
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
Store these somewhere safe. Each recovery code can only be used once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UButton to="/two-factor-setup/complete" block trailing-icon="i-lucide-arrow-right">
|
||||
Continue
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-else-if="!twoFactor.pending"
|
||||
class="space-y-4"
|
||||
@submit.prevent="enable"
|
||||
>
|
||||
<p class="text-sm text-muted">
|
||||
Start by confirming your identity. You will then connect an authenticator app.
|
||||
</p>
|
||||
|
||||
<UFormField
|
||||
v-if="twoFactor.requiresPassword"
|
||||
label="Current password"
|
||||
name="password"
|
||||
:error="enableForm.errors.password"
|
||||
>
|
||||
<UInput
|
||||
v-model="enableForm.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UAlert
|
||||
v-else
|
||||
color="info"
|
||||
variant="subtle"
|
||||
icon="i-lucide-info"
|
||||
title="Your social login has confirmed your identity."
|
||||
/>
|
||||
|
||||
<UButton type="submit" block :loading="enableForm.processing">
|
||||
Set up authenticator
|
||||
</UButton>
|
||||
</form>
|
||||
|
||||
<div v-else class="space-y-5">
|
||||
<div class="space-y-3 text-center">
|
||||
<img
|
||||
v-if="twoFactor.qrCodeDataUri"
|
||||
:src="twoFactor.qrCodeDataUri"
|
||||
alt="Two-factor authenticator QR code"
|
||||
class="mx-auto size-48 rounded-lg border bg-white p-2"
|
||||
>
|
||||
<div>
|
||||
<h2 class="font-medium">
|
||||
Scan the QR code
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Use any TOTP-compatible authenticator app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-1 text-xs text-muted">
|
||||
Manual setup key
|
||||
</p>
|
||||
<div class="flex items-center gap-2 rounded-lg bg-elevated p-2">
|
||||
<code class="min-w-0 flex-1 break-all px-1 text-sm">
|
||||
{{ twoFactor.secretKey }}
|
||||
</code>
|
||||
<UButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:icon="manualKeyCopied ? 'i-lucide-check' : 'i-lucide-copy'"
|
||||
:color="manualKeyCopied ? 'success' : 'neutral'"
|
||||
:aria-label="manualKeyCopied ? 'Manual setup key copied' : 'Copy manual setup key'"
|
||||
@click="copyManualKey(twoFactor.secretKey)"
|
||||
>
|
||||
{{ manualKeyCopied ? 'Copied' : 'Copy' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="confirm">
|
||||
<UFormField
|
||||
label="Six-digit authentication code"
|
||||
name="code"
|
||||
:error="confirmForm.errors.code"
|
||||
>
|
||||
<UInput
|
||||
v-model="confirmForm.code"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
placeholder="123456"
|
||||
autofocus
|
||||
class="w-full"
|
||||
:ui="{ base: 'text-center tracking-[0.35em]' }"
|
||||
/>
|
||||
</UFormField>
|
||||
<UButton type="submit" block :loading="confirmForm.processing">
|
||||
Verify authenticator
|
||||
</UButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="!twoFactor.enabled" class="border-t pt-4">
|
||||
<UButton
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-log-out"
|
||||
block
|
||||
:loading="logoutForm.processing"
|
||||
@click="logout"
|
||||
>
|
||||
Sign out and finish later
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
import RecoveryCodesPanel from '@/components/auth/RecoveryCodesPanel.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
|
||||
@@ -235,19 +236,24 @@ function recoveryCodesAction(regenerate: boolean) {
|
||||
</p>
|
||||
</div>
|
||||
<UFormField
|
||||
:label="twoFactor.requiresPassword ? 'Current password' : 'Authentication or recovery code'"
|
||||
:name="twoFactor.requiresPassword ? 'password' : 'code'"
|
||||
:error="recoveryForm.errors.password || recoveryForm.errors.code"
|
||||
v-if="twoFactor.requiresPassword"
|
||||
label="Current password"
|
||||
name="password"
|
||||
:error="recoveryForm.errors.password"
|
||||
>
|
||||
<UInput
|
||||
v-if="twoFactor.requiresPassword"
|
||||
v-model="recoveryForm.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Authentication or recovery code"
|
||||
name="code"
|
||||
:error="recoveryForm.errors.code"
|
||||
>
|
||||
<UInput
|
||||
v-else
|
||||
v-model="recoveryForm.code"
|
||||
autocomplete="one-time-code"
|
||||
class="w-full"
|
||||
@@ -272,7 +278,16 @@ function recoveryCodesAction(regenerate: boolean) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4 border-t pt-6" @submit.prevent="disable">
|
||||
<UAlert
|
||||
v-if="twoFactorRequired"
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
title="Two-factor authentication is required by your administrator."
|
||||
description="It cannot be disabled while the mandatory security policy is active."
|
||||
/>
|
||||
|
||||
<form v-else class="space-y-4 border-t pt-6" @submit.prevent="disable">
|
||||
<div>
|
||||
<h3 class="font-medium text-error">
|
||||
Disable two-factor authentication
|
||||
@@ -282,19 +297,24 @@ function recoveryCodesAction(regenerate: boolean) {
|
||||
</p>
|
||||
</div>
|
||||
<UFormField
|
||||
:label="twoFactor.requiresPassword ? 'Current password' : 'Authentication or recovery code'"
|
||||
:name="twoFactor.requiresPassword ? 'password' : 'code'"
|
||||
:error="disableForm.errors.password || disableForm.errors.code"
|
||||
v-if="twoFactor.requiresPassword"
|
||||
label="Current password"
|
||||
name="password"
|
||||
:error="disableForm.errors.password"
|
||||
>
|
||||
<UInput
|
||||
v-if="twoFactor.requiresPassword"
|
||||
v-model="disableForm.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Authentication or recovery code"
|
||||
name="code"
|
||||
:error="disableForm.errors.code"
|
||||
>
|
||||
<UInput
|
||||
v-else
|
||||
v-model="disableForm.code"
|
||||
autocomplete="one-time-code"
|
||||
class="w-full"
|
||||
@@ -318,13 +338,7 @@ function recoveryCodesAction(regenerate: boolean) {
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<code
|
||||
v-for="code in recoveryCodes"
|
||||
:key="code"
|
||||
class="rounded bg-elevated px-3 py-2 text-sm"
|
||||
>{{ code }}</code>
|
||||
</div>
|
||||
<RecoveryCodesPanel :codes="recoveryCodes" />
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
78
resources/js/components/auth/RecoveryCodesPanel.vue
Normal file
78
resources/js/components/auth/RecoveryCodesPanel.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
codes: string[]
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
function recoveryCodesText() {
|
||||
return [
|
||||
'Two-factor authentication recovery codes',
|
||||
'',
|
||||
...props.codes,
|
||||
'',
|
||||
'Each code can only be used once. Store these codes securely.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function copyAll() {
|
||||
await navigator.clipboard.writeText(props.codes.join('\n'))
|
||||
copied.value = true
|
||||
|
||||
window.setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function download() {
|
||||
const blob = new Blob([recoveryCodesText()], {
|
||||
type: 'text/plain;charset=utf-8',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
link.download = 'two-factor-recovery-codes.txt'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:color="copied ? 'success' : 'neutral'"
|
||||
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
|
||||
:aria-label="copied ? 'Recovery codes copied' : 'Copy all recovery codes'"
|
||||
@click="copyAll"
|
||||
>
|
||||
{{ copied ? 'Copied' : 'Copy all' }}
|
||||
</UButton>
|
||||
<UButton
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-download"
|
||||
aria-label="Download recovery codes"
|
||||
@click="download"
|
||||
>
|
||||
Download
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<code
|
||||
v-for="code in codes"
|
||||
:key="code"
|
||||
class="rounded bg-elevated px-3 py-2 text-center text-sm"
|
||||
>{{ code }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -40,8 +40,8 @@ describe('registerSchema', () => {
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
password_confirmation: 'password123',
|
||||
password: 'correct horse battery staple',
|
||||
password_confirmation: 'correct horse battery staple',
|
||||
}
|
||||
|
||||
it('accepts valid registration data', () => {
|
||||
@@ -69,8 +69,8 @@ describe('registerSchema', () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects password shorter than 8 characters', () => {
|
||||
const result = validate(registerSchema, { ...validData, password: 'short', password_confirmation: 'short' })
|
||||
it('rejects password shorter than 15 characters', () => {
|
||||
const result = validate(registerSchema, { ...validData, password: 'too-short', password_confirmation: 'too-short' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
@@ -110,8 +110,8 @@ describe('forgotPasswordSchema', () => {
|
||||
describe('resetPasswordSchema', () => {
|
||||
const validData = {
|
||||
email: 'test@example.com',
|
||||
password: 'newpassword123',
|
||||
password_confirmation: 'newpassword123',
|
||||
password: 'a secure new password',
|
||||
password_confirmation: 'a secure new password',
|
||||
}
|
||||
|
||||
it('accepts valid reset data', () => {
|
||||
@@ -124,8 +124,8 @@ describe('resetPasswordSchema', () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects password shorter than 8 characters', () => {
|
||||
const result = validate(resetPasswordSchema, { ...validData, password: 'short', password_confirmation: 'short' })
|
||||
it('rejects password shorter than 15 characters', () => {
|
||||
const result = validate(resetPasswordSchema, { ...validData, password: 'too-short', password_confirmation: 'too-short' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const registerSchema = v.pipe(
|
||||
first_name: v.pipe(v.string('First name is required'), v.nonEmpty('First name is required')),
|
||||
last_name: v.pipe(v.string('Last name is required'), v.nonEmpty('Last name is required')),
|
||||
email: v.pipe(v.string('Email is required'), v.nonEmpty('Email is required'), v.email('Please enter a valid email')),
|
||||
password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required'), v.minLength(8, 'Password must be at least 8 characters')),
|
||||
password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required'), v.minLength(15, 'Password must be at least 15 characters'), v.maxLength(128, 'Password must be 128 characters or fewer')),
|
||||
password_confirmation: v.pipe(v.string('Please confirm your password'), v.nonEmpty('Please confirm your password')),
|
||||
}),
|
||||
v.forward(
|
||||
@@ -37,7 +37,7 @@ export const forgotPasswordSchema = v.object({
|
||||
export const resetPasswordSchema = v.pipe(
|
||||
v.object({
|
||||
email: v.pipe(v.string('Email is required'), v.nonEmpty('Email is required'), v.email('Please enter a valid email')),
|
||||
password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required'), v.minLength(8, 'Password must be at least 8 characters')),
|
||||
password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required'), v.minLength(15, 'Password must be at least 15 characters'), v.maxLength(128, 'Password must be 128 characters or fewer')),
|
||||
password_confirmation: v.pipe(v.string('Please confirm your password'), v.nonEmpty('Please confirm your password')),
|
||||
}),
|
||||
v.forward(
|
||||
|
||||
Reference in New Issue
Block a user