2 Commits

Author SHA1 Message Date
50ed370d24 Merge pull request 'Add optional TOTP MFA for email/password accounts' (#76) from feature/optional-totp-mfa into develop
All checks were successful
CI / backend-tests (push) Successful in 31s
CI / frontend-tests (push) Successful in 29s
Reviewed-on: #76
2026-03-22 09:21:33 +01:00
7a828d7215 feat: add optional TOTP MFA for email/password accounts
All checks were successful
CI / backend-tests (pull_request) Successful in 26s
CI / frontend-tests (pull_request) Successful in 28s
- Add MFA enrollment UI in new Settings page with QR code and backup secret
- Add TOTP challenge step to login flow for enrolled users
- Check AAL after login and show TOTP input when aal2 required
- Add disable MFA option with TOTP re-verification
- Only show MFA options for email/password users (not OAuth)
- Add Settings link to user dropdown menu

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:56:48 +01:00
7 changed files with 610 additions and 11 deletions

View File

@@ -0,0 +1,56 @@
---
# nuzlocke-tracker-f2hs
title: Optional TOTP MFA for email/password accounts
status: in-progress
type: feature
priority: normal
created_at: 2026-03-21T12:19:18Z
updated_at: 2026-03-21T12:56:34Z
parent: nuzlocke-tracker-wwnu
---
## Problem
Users who sign up with email/password have no MFA option. Google/Discord OAuth users get their provider's MFA, but email-only users have a weaker security posture.
## Approach
Supabase has built-in TOTP MFA support via the `supabase.auth.mfa` API. This should be optional — users can enable it from their profile/settings page.
### Backend
- No backend changes needed — Supabase handles MFA enrollment and verification at the auth layer
- JWT tokens from MFA-enrolled users include an `aal` (authenticator assurance level) claim; optionally validate `aal2` for sensitive operations in the future
### Frontend
1. Add MFA setup flow to user profile/settings page:
- "Enable MFA" button → calls `supabase.auth.mfa.enroll({ factorType: 'totp' })`
- Show QR code from enrollment response
- Verify with TOTP code → `supabase.auth.mfa.challengeAndVerify()`
2. Add MFA challenge during login:
- After email/password sign-in, check `supabase.auth.mfa.getAuthenticatorAssuranceLevel()`
- If `currentLevel === 'aal1'` and `nextLevel === 'aal2'`, show TOTP input
- Verify → `supabase.auth.mfa.challengeAndVerify()`
3. Add "Disable MFA" option with re-verification
4. Only show MFA options for email/password users (not OAuth)
### UX
- Settings page: toggle to enable/disable MFA
- Login flow: TOTP input step after password for enrolled users
- Recovery: Supabase provides recovery codes during enrollment — display them
## Files to modify
- `frontend/src/pages/` — new MFA settings component or add to existing profile page
- `frontend/src/pages/Login.tsx` — add MFA challenge step
- `frontend/src/contexts/AuthContext.tsx` — handle AAL levels
## Checklist
- [x] Add MFA enrollment UI (QR code, verification) to profile/settings
- [x] Display backup secret code after enrollment (Supabase TOTP doesn't provide recovery codes)
- [x] Add TOTP challenge step to login flow
- [x] Check AAL after login and redirect to TOTP if needed
- [x] Add "Disable MFA" with re-verification
- [x] Only show MFA options for email/password users
- [ ] Test: full enrollment → login → TOTP flow
- [N/A] Test: recovery code works when TOTP unavailable (Supabase doesn't provide recovery codes; users save their secret key instead)

View File

@@ -12,6 +12,7 @@ import {
NewRun,
RunList,
RunEncounters,
Settings,
Signup,
Stats,
} from './pages'
@@ -42,6 +43,7 @@ function App() {
<Route path="genlockes/new" element={<ProtectedRoute><NewGenlocke /></ProtectedRoute>} />
<Route path="genlockes/:genlockeId" element={<GenlockeDetail />} />
<Route path="stats" element={<Stats />} />
<Route path="settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<Route
path="runs/:runId/encounters"
element={<Navigate to=".." relative="path" replace />}

View File

@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react'
import { Link, Outlet, useLocation } from 'react-router-dom'
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useTheme } from '../hooks/useTheme'
import { useAuth } from '../contexts/AuthContext'
@@ -67,6 +67,7 @@ function ThemeToggle() {
function UserMenu({ onAction }: { onAction?: () => void }) {
const { user, loading, signOut } = useAuth()
const [open, setOpen] = useState(false)
const navigate = useNavigate()
if (loading) {
return <div className="w-8 h-8 rounded-full bg-surface-3 animate-pulse" />
@@ -106,6 +107,17 @@ function UserMenu({ onAction }: { onAction?: () => void }) {
<p className="text-sm text-text-primary truncate">{email}</p>
</div>
<div className="py-1">
<button
type="button"
onClick={() => {
setOpen(false)
onAction?.()
navigate('/settings')
}}
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
>
Settings
</button>
<button
type="button"
onClick={async () => {

View File

@@ -1,5 +1,5 @@
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
import type { User, Session, AuthError } from '@supabase/supabase-js'
import type { User, Session, AuthError, Factor } from '@supabase/supabase-js'
import { supabase } from '../lib/supabase'
import { api } from '../api/client'
@@ -10,19 +10,42 @@ interface UserProfile {
isAdmin: boolean
}
interface MfaState {
requiresMfa: boolean
factorId: string | null
enrolledFactors: Factor[]
}
interface AuthState {
user: User | null
session: Session | null
loading: boolean
isAdmin: boolean
mfa: MfaState
}
interface MfaEnrollResult {
factorId: string
qrCode: string
secret: string
recoveryCodes?: string[]
}
interface AuthContextValue extends AuthState {
signInWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
signInWithEmail: (
email: string,
password: string
) => Promise<{ error: AuthError | null; requiresMfa?: boolean }>
signUpWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
signInWithGoogle: () => Promise<{ error: AuthError | null }>
signInWithDiscord: () => Promise<{ error: AuthError | null }>
signOut: () => Promise<void>
verifyMfa: (code: string) => Promise<{ error: AuthError | null }>
enrollMfa: () => Promise<{ data: MfaEnrollResult | null; error: AuthError | null }>
verifyMfaEnrollment: (factorId: string, code: string) => Promise<{ error: AuthError | null }>
unenrollMfa: (factorId: string) => Promise<{ error: AuthError | null }>
isOAuthUser: boolean
refreshMfaState: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
@@ -37,25 +60,49 @@ async function syncUserProfile(session: Session | null): Promise<boolean> {
}
}
async function getMfaState(): Promise<MfaState> {
const defaultState: MfaState = { requiresMfa: false, factorId: null, enrolledFactors: [] }
try {
const { data: aalData } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (!aalData) return defaultState
const { data: factorsData } = await supabase.auth.mfa.listFactors()
const verifiedFactors = factorsData?.totp?.filter((f) => f.status === 'verified') ?? []
const requiresMfa = aalData.currentLevel === 'aal1' && aalData.nextLevel === 'aal2'
const factorId = requiresMfa ? (verifiedFactors[0]?.id ?? null) : null
return { requiresMfa, factorId, enrolledFactors: verifiedFactors }
} catch {
return defaultState
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({
user: null,
session: null,
loading: true,
isAdmin: false,
mfa: { requiresMfa: false, factorId: null, enrolledFactors: [] },
})
const refreshMfaState = useCallback(async () => {
const mfa = await getMfaState()
setState((prev) => ({ ...prev, mfa }))
}, [])
useEffect(() => {
supabase.auth.getSession().then(async ({ data: { session } }) => {
const isAdmin = await syncUserProfile(session)
setState({ user: session?.user ?? null, session, loading: false, isAdmin })
const [isAdmin, mfa] = await Promise.all([syncUserProfile(session), getMfaState()])
setState({ user: session?.user ?? null, session, loading: false, isAdmin, mfa })
})
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (_event, session) => {
const isAdmin = await syncUserProfile(session)
setState({ user: session?.user ?? null, session, loading: false, isAdmin })
const [isAdmin, mfa] = await Promise.all([syncUserProfile(session), getMfaState()])
setState({ user: session?.user ?? null, session, loading: false, isAdmin, mfa })
})
return () => subscription.unsubscribe()
@@ -63,7 +110,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const signInWithEmail = useCallback(async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password })
return { error }
if (error) return { error }
const mfa = await getMfaState()
setState((prev) => ({ ...prev, mfa }))
return { error: null, requiresMfa: mfa.requiresMfa }
}, [])
const signUpWithEmail = useCallback(async (email: string, password: string) => {
@@ -91,6 +142,79 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await supabase.auth.signOut()
}, [])
const verifyMfa = useCallback(
async (code: string) => {
const factorId = state.mfa.factorId
if (!factorId) {
return { error: { message: 'No MFA factor found' } as AuthError }
}
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
factorId,
})
if (challengeError) return { error: challengeError }
const { error } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code,
})
if (!error) {
const mfa = await getMfaState()
setState((prev) => ({ ...prev, mfa }))
}
return { error }
},
[state.mfa.factorId]
)
const enrollMfa = useCallback(async () => {
const { data, error } = await supabase.auth.mfa.enroll({ factorType: 'totp' })
if (error || !data) {
return { data: null, error }
}
return {
data: {
factorId: data.id,
qrCode: data.totp.qr_code,
secret: data.totp.secret,
},
error: null,
}
}, [])
const verifyMfaEnrollment = useCallback(async (factorId: string, code: string) => {
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
factorId,
})
if (challengeError) return { error: challengeError }
const { error } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code,
})
if (!error) {
const mfa = await getMfaState()
setState((prev) => ({ ...prev, mfa }))
}
return { error }
}, [])
const unenrollMfa = useCallback(async (factorId: string) => {
const { error } = await supabase.auth.mfa.unenroll({ factorId })
if (!error) {
const mfa = await getMfaState()
setState((prev) => ({ ...prev, mfa }))
}
return { error }
}, [])
const isOAuthUser = useMemo(() => {
if (!state.user) return false
const provider = state.user.app_metadata?.['provider']
return provider === 'google' || provider === 'discord'
}, [state.user])
const value = useMemo(
() => ({
...state,
@@ -99,8 +223,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
signInWithGoogle,
signInWithDiscord,
signOut,
verifyMfa,
enrollMfa,
verifyMfaEnrollment,
unenrollMfa,
isOAuthUser,
refreshMfaState,
}),
[state, signInWithEmail, signUpWithEmail, signInWithGoogle, signInWithDiscord, signOut]
[
state,
signInWithEmail,
signUpWithEmail,
signInWithGoogle,
signInWithDiscord,
signOut,
verifyMfa,
enrollMfa,
verifyMfaEnrollment,
unenrollMfa,
isOAuthUser,
refreshMfaState,
]
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>

View File

@@ -7,9 +7,11 @@ const isLocalDev = import.meta.env['VITE_SUPABASE_URL']?.includes('localhost') ?
export function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const { signInWithEmail, signInWithGoogle, signInWithDiscord } = useAuth()
const [showMfaChallenge, setShowMfaChallenge] = useState(false)
const { signInWithEmail, signInWithGoogle, signInWithDiscord, verifyMfa } = useAuth()
const navigate = useNavigate()
const location = useLocation()
@@ -20,11 +22,29 @@ export function Login() {
setError(null)
setLoading(true)
const { error } = await signInWithEmail(email, password)
const { error, requiresMfa } = await signInWithEmail(email, password)
setLoading(false)
if (error) {
setError(error.message)
} else if (requiresMfa) {
setShowMfaChallenge(true)
} else {
navigate(from, { replace: true })
}
}
async function handleMfaSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
const { error } = await verifyMfa(totpCode)
setLoading(false)
if (error) {
setError(error.message)
setTotpCode('')
} else {
navigate(from, { replace: true })
}
@@ -42,6 +62,68 @@ export function Login() {
if (error) setError(error.message)
}
if (showMfaChallenge) {
return (
<div className="min-h-[80vh] flex items-center justify-center px-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<h1 className="text-2xl font-bold">Two-Factor Authentication</h1>
<p className="text-text-secondary mt-1">Enter the code from your authenticator app</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleMfaSubmit} className="space-y-4">
<div>
<label
htmlFor="totp-code"
className="block text-sm font-medium text-text-secondary mb-1"
>
Authentication code
</label>
<input
id="totp-code"
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
autoFocus
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 text-center text-lg tracking-widest font-mono"
autoComplete="one-time-code"
/>
</div>
<button
type="submit"
disabled={totpCode.length !== 6 || loading}
className="w-full py-2 px-4 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
{loading ? 'Verifying...' : 'Verify'}
</button>
</form>
<button
type="button"
onClick={() => {
setShowMfaChallenge(false)
setTotpCode('')
setError(null)
}}
className="w-full text-center text-sm text-text-secondary hover:text-text-primary"
>
Back to login
</button>
</div>
</div>
)
}
return (
<div className="min-h-[80vh] flex items-center justify-center px-4">
<div className="w-full max-w-sm space-y-6">

View File

@@ -0,0 +1,303 @@
import { useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
type MfaStep = 'idle' | 'enrolling' | 'verifying' | 'success' | 'disabling'
interface EnrollmentData {
factorId: string
qrCode: string
secret: string
}
export function Settings() {
const { user, loading, mfa, isOAuthUser, enrollMfa, verifyMfaEnrollment, unenrollMfa } = useAuth()
const [step, setStep] = useState<MfaStep>('idle')
const [enrollmentData, setEnrollmentData] = useState<EnrollmentData | null>(null)
const [code, setCode] = useState('')
const [error, setError] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState(false)
const [disableFactorId, setDisableFactorId] = useState<string | null>(null)
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-4 border-accent-600 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!user) {
return <Navigate to="/login" replace />
}
const hasMfa = mfa.enrolledFactors.length > 0
async function handleEnroll() {
setError(null)
setActionLoading(true)
const { data, error: enrollError } = await enrollMfa()
setActionLoading(false)
if (enrollError || !data) {
setError(enrollError?.message ?? 'Failed to start MFA enrollment')
return
}
setEnrollmentData(data)
setStep('enrolling')
}
async function handleVerifyEnrollment(e: React.FormEvent) {
e.preventDefault()
if (!enrollmentData) return
setError(null)
setActionLoading(true)
const { error: verifyError } = await verifyMfaEnrollment(enrollmentData.factorId, code)
setActionLoading(false)
if (verifyError) {
setError(verifyError.message)
return
}
setStep('success')
setCode('')
}
async function handleStartDisable(factorId: string) {
setDisableFactorId(factorId)
setStep('disabling')
setCode('')
setError(null)
}
async function handleConfirmDisable(e: React.FormEvent) {
e.preventDefault()
if (!disableFactorId) return
setError(null)
setActionLoading(true)
const { error: unenrollError } = await unenrollMfa(disableFactorId)
setActionLoading(false)
if (unenrollError) {
setError(unenrollError.message)
return
}
setStep('idle')
setDisableFactorId(null)
setCode('')
}
function handleCancel() {
setStep('idle')
setEnrollmentData(null)
setDisableFactorId(null)
setCode('')
setError(null)
}
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
<section className="bg-surface-1 rounded-lg border border-border-default p-6">
<h2 className="text-lg font-semibold mb-4">Two-Factor Authentication</h2>
{isOAuthUser ? (
<p className="text-text-secondary text-sm">
You signed in with an OAuth provider (Google/Discord). MFA is managed by your provider.
</p>
) : (
<>
{step === 'idle' && (
<>
{hasMfa ? (
<div className="space-y-4">
<div className="flex items-center gap-2 text-green-400">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
<span className="font-medium">MFA is enabled</span>
</div>
<p className="text-text-secondary text-sm">
Your account is protected with two-factor authentication.
</p>
<button
type="button"
onClick={() => handleStartDisable(mfa.enrolledFactors[0]?.id ?? '')}
className="px-4 py-2 border border-red-500/50 text-red-400 rounded-lg hover:bg-red-500/10 transition-colors"
>
Disable MFA
</button>
</div>
) : (
<div className="space-y-4">
<p className="text-text-secondary text-sm">
Add an extra layer of security to your account by enabling two-factor
authentication with an authenticator app.
</p>
<button
type="button"
onClick={handleEnroll}
disabled={actionLoading}
className="px-4 py-2 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 text-white rounded-lg transition-colors"
>
{actionLoading ? 'Setting up...' : 'Enable MFA'}
</button>
{error && <p className="text-red-400 text-sm">{error}</p>}
</div>
)}
</>
)}
{step === 'enrolling' && enrollmentData && (
<div className="space-y-4">
<p className="text-text-secondary text-sm">
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.):
</p>
<div className="flex justify-center p-4 bg-white rounded-lg">
<img src={enrollmentData.qrCode} alt="MFA QR Code" className="w-48 h-48" />
</div>
<div className="bg-surface-2 rounded-lg p-4 space-y-2">
<p className="text-xs text-text-tertiary">
Manual entry code (save this as a backup):
</p>
<code className="block text-sm font-mono bg-surface-3 px-3 py-2 rounded select-all text-center break-all">
{enrollmentData.secret}
</code>
<p className="text-xs text-yellow-500">
Save this code securely. You can use it to restore your authenticator if you
lose access to your device.
</p>
</div>
<form onSubmit={handleVerifyEnrollment} className="space-y-4">
<div>
<label
htmlFor="totp-code"
className="block text-sm font-medium text-text-secondary mb-1"
>
Enter the 6-digit code from your app
</label>
<input
id="totp-code"
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 text-center text-lg tracking-widest font-mono"
autoComplete="one-time-code"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={code.length !== 6 || actionLoading}
className="flex-1 px-4 py-2 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 text-white rounded-lg transition-colors"
>
{actionLoading ? 'Verifying...' : 'Verify & Enable'}
</button>
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 border border-border-default rounded-lg hover:bg-surface-2 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
)}
{step === 'success' && (
<div className="space-y-4">
<div className="flex items-center gap-2 text-green-400">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span className="font-semibold">MFA enabled successfully!</span>
</div>
<p className="text-text-secondary text-sm">
Your account is now protected with two-factor authentication. You&apos;ll need to
enter a code from your authenticator app each time you sign in.
</p>
<button
type="button"
onClick={() => setStep('idle')}
className="px-4 py-2 bg-accent-600 hover:bg-accent-700 text-white rounded-lg transition-colors"
>
Done
</button>
</div>
)}
{step === 'disabling' && (
<form onSubmit={handleConfirmDisable} className="space-y-4">
<p className="text-text-secondary text-sm">
To disable MFA, enter a code from your authenticator app to confirm.
</p>
<div>
<label
htmlFor="disable-code"
className="block text-sm font-medium text-text-secondary mb-1"
>
Authentication code
</label>
<input
id="disable-code"
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 text-center text-lg tracking-widest font-mono"
autoComplete="one-time-code"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={code.length !== 6 || actionLoading}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:opacity-50 text-white rounded-lg transition-colors"
>
{actionLoading ? 'Disabling...' : 'Disable MFA'}
</button>
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 border border-border-default rounded-lg hover:bg-surface-2 transition-colors"
>
Cancel
</button>
</div>
</form>
)}
</>
)}
</section>
</div>
)
}

View File

@@ -8,5 +8,6 @@ export { NewGenlocke } from './NewGenlocke'
export { NewRun } from './NewRun'
export { RunList } from './RunList'
export { RunEncounters } from './RunEncounters'
export { Settings } from './Settings'
export { Signup } from './Signup'
export { Stats } from './Stats'