feat: upgrade auth to email+password with optional TOTP MFA

- Replace magic-link-only login with email+password (primary) and magic link (toggle)
- Add registration page with strong password validation
- Add MFA enrollment (/mfa/enroll) with QR code and manual secret
- Add MFA verification (/mfa/verify) with 6-digit TOTP input
- Add password reset flow (/reset-password)
- Add middleware MFA enforcement gated by NEXT_PUBLIC_REQUIRE_MFA env var
- Self-hosted deployments (NEXT_PUBLIC_SELF_HOSTED=true) skip MFA entirely
- Add Security tab in Settings for password change and MFA management
- Add requireAuth() API route helper with MFA check
- Update CLAUDE.md with Authentication section and env var docs
- Update Dockerfile and docker-entrypoint.sh for new env var placeholders

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-08 10:17:49 +01:00
co-authored by Claude Opus 4.6
parent e93f5e06d5
commit 928a145f9a
14 changed files with 1497 additions and 49 deletions
+11
View File
@@ -0,0 +1,11 @@
/**
* MFA (Multi-Factor Authentication) helpers.
*
* MFA is only required on the hosted version, never for self-hosted deployments.
* Enforcement is application-side (middleware + API routes), not RLS.
*/
export function isMfaRequired(): boolean {
if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false
return process.env.NEXT_PUBLIC_REQUIRE_MFA === 'true'
}
+40
View File
@@ -0,0 +1,40 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { isMfaRequired } from './mfa'
import type { User, SupabaseClient } from '@supabase/supabase-js'
type AuthResult =
| { user: User; supabase: SupabaseClient; error: null }
| { user: null; supabase: SupabaseClient; error: NextResponse }
/**
* Auth + MFA guard for API routes.
*
* Returns the authenticated user and Supabase client, or a JSON error response.
* When MFA is required (hosted deployment), verifies AAL2 assurance level.
*/
export async function requireAuth(): Promise<AuthResult> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return {
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
}
}
if (isMfaRequired()) {
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (aal?.nextLevel === 'aal2' && aal?.currentLevel !== 'aal2') {
return {
user: null,
supabase,
error: NextResponse.json({ error: 'MFA verification required' }, { status: 403 }),
}
}
}
return { user, supabase, error: null }
}
+35 -4
View File
@@ -1,5 +1,6 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
import { isMfaRequired } from '@/lib/auth/mfa'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
@@ -48,11 +49,15 @@ export async function updateSession(request: NextRequest) {
await supabase.auth.signOut()
}
// Auth routes - allow access
if (pathname.startsWith('/login') || pathname.startsWith('/auth')) {
// If user is logged in and trying to access login, redirect to dashboard or onboarding
// Public auth routes — allow access
if (
pathname.startsWith('/login') ||
pathname.startsWith('/register') ||
pathname.startsWith('/auth') ||
pathname.startsWith('/reset-password')
) {
// If user is logged in and trying to access auth pages, redirect to dashboard or onboarding
if (user) {
// Check if onboarding is complete
const { data: settings } = await supabase
.from('company_settings')
.select('onboarding_complete')
@@ -75,6 +80,32 @@ export async function updateSession(request: NextRequest) {
return NextResponse.redirect(url)
}
// MFA pages — accessible to authenticated users (AAL1+), skip MFA enforcement
if (pathname.startsWith('/mfa/')) {
return supabaseResponse
}
// MFA enforcement (application-side only, not RLS)
if (isMfaRequired()) {
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
// User has MFA enrolled but hasn't verified this session → redirect to verify
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
return NextResponse.redirect(new URL('/mfa/verify', request.url))
}
// MFA required but user has no factor enrolled yet → force enrollment
// (skip during onboarding — let them finish setup first)
if (!pathname.startsWith('/onboarding')) {
const { data: factors } = await supabase.auth.mfa.listFactors()
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
if (!hasVerifiedFactor) {
return NextResponse.redirect(new URL('/mfa/enroll', request.url))
}
}
}
// Onboarding route - only accessible if not complete
if (pathname.startsWith('/onboarding')) {
const { data: settings } = await supabase