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>
This commit is contained in:
2026-03-21 13:56:48 +01:00
parent a12958ae32
commit 7a828d7215
7 changed files with 610 additions and 11 deletions

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>