3bb178b5-949c-4434-a4e8-093caab5d33b

This commit is contained in:
Z User committed 2026-10-29 23:59:48 +00:00
1 parent cd7591fd65
commit 72ec15c2ab
21 files changed
+1991 -130

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { getCurrentUser } from '@/lib/session'
import { verifyPassword } from '@/lib/auth'
export const dynamic = 'force-dynamic'
const disableSchema = z.object({
password: z.string().min(1),
})
// POST /api/auth/2fa/disable
//
// Disables 2FA for the authenticated user. Requires the current password
// (re-authentication) so a hijacked session can't silently weaken security.
// Clears the TOTP secret + backup codes; the user row is retained.
//
// Audit-logged.
export async function POST(req: Request) {
try {
const parsed = disableSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'Passwort erforderlich.' }, { status: 400 })
}
const { password } = parsed.data
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
const full = await db.user.findUnique({ where: { id: user.id }, select: { passwordHash: true, twoFactorMethod: true } })
if (!full?.passwordHash) {
return NextResponse.json({ error: 'Kein Passwort gesetzt.' }, { status: 400 })
}
if (!verifyPassword(password, full.passwordHash)) {
return NextResponse.json({ error: 'Falsches Passwort.' }, { status: 401 })
}
await db.user.update({
where: { id: user.id },
data: {
twoFactorEnabled: false,
twoFactorMethod: null,
twoFactorSecret: null,
twoFactorBackup: null,
twoFactorEnabledAt: null,
},
})
await db.auditLog.create({
data: {
actor: `user:${user.email}`,
action: 'auth.2fa.disable',
entity: 'User',
entityId: user.id,
before: JSON.stringify({ method: full.twoFactorMethod }),
after: JSON.stringify({ disabled: true }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, message: '2FA deaktiviert.' })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+107
View File
@@ -0,0 +1,107 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { getCurrentUser } from '@/lib/session'
import {
verifyTotp,
generateBackupCodes,
} from '@/lib/auth'
export const dynamic = 'force-dynamic'
const enableSchema = z.object({
method: z.enum(['totp', 'email']),
// Required when method='totp': the secret from /2fa/setup + a code the
// user enters from their authenticator app to prove they scanned it.
secret: z.string().optional(),
code: z.string().min(6).max(6).optional(),
// Re-auth: user must confirm current password to enable 2FA (anti-session-hijack).
password: z.string().min(1),
})
// POST /api/auth/2fa/enable
//
// Enables 2FA for the authenticated user. Requires the current password
// (re-authentication) so a hijacked session can't silently lock the owner
// out by enabling 2FA.
//
// method='totp' → secret + code must be present; code verified against
// the secret (proves the user scanned the QR).
// method='email' → no secret needed; OTP delivery happens at login time.
//
// On success, 8 backup codes are generated and returned ONCE (the user must
// save them — they're stored hashed and shown only here).
export async function POST(req: Request) {
try {
const parsed = enableSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'Angaben ungültig', details: parsed.error.flatten() }, { status: 400 })
}
const { method, secret, code, password } = parsed.data
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
// Re-auth: verify current password.
const full = await db.user.findUnique({ where: { id: user.id }, select: { passwordHash: true } })
if (!full?.passwordHash) {
return NextResponse.json({ error: 'Kein Passwort gesetzt.' }, { status: 400 })
}
const { verifyPassword } = await import('@/lib/auth')
if (!verifyPassword(password, full.passwordHash)) {
return NextResponse.json({ error: 'Falsches Passwort.' }, { status: 401 })
}
if (method === 'totp') {
if (!secret || !code) {
return NextResponse.json(
{ error: 'Secret und Code aus der Authenticator-App erforderlich.' },
{ status: 400 }
)
}
if (!verifyTotp(code, secret)) {
return NextResponse.json({ error: 'Code aus der App ist falsch — bitte erneut versuchen.' }, { status: 401 })
}
}
// Generate backup codes (only when enabling — not when switching methods
// if already enabled, but here we regenerate for simplicity).
const { plain: backupPlain, hashed: backupHashed } = generateBackupCodes(8)
await db.user.update({
where: { id: user.id },
data: {
twoFactorEnabled: true,
twoFactorMethod: method,
twoFactorSecret: method === 'totp' ? secret : null,
twoFactorBackup: backupHashed,
twoFactorEnabledAt: new Date(),
},
})
await db.auditLog.create({
data: {
actor: `user:${user.email}`,
action: 'auth.2fa.enable',
entity: 'User',
entityId: user.id,
after: JSON.stringify({ method }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
ok: true,
method,
backupCodes: backupPlain,
message: method === 'totp'
? '2FA mit Authenticator-App aktiviert. Backup-Codes sicher speichern!'
: '2FA per E-Mail aktiviert. Backup-Codes sicher speichern!',
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import QRCode from 'qrcode'
import { getCurrentUser } from '@/lib/session'
import { generateTotpSecret, totpOtpauthUrl } from '@/lib/auth'
export const dynamic = 'force-dynamic'
// POST /api/auth/2fa/setup
//
// Initiates TOTP setup for the authenticated user. Generates a fresh secret
// (NOT persisted yet — the client holds it and submits it back with a code
// to /2fa/enable). Returns the otpauth:// URL and a PNG data-URI QR code
// the user scans with Google Authenticator / Authy / 1Password.
//
// For the 'email' method there is nothing to set up — the frontend should
// call /2fa/enable directly with method='email'.
export async function POST() {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
const secret = generateTotpSecret()
const label = `${user.email}`
const issuer = 'AOS'
const otpauthUrl = totpOtpauthUrl({ secret, label, issuer })
const qr = await QRCode.toDataURL(otpauthUrl, {
width: 220,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
})
return NextResponse.json({
secret,
otpauthUrl,
qr,
issuer,
label,
// Manual entry key, grouped for readability.
secretFormatted: secret.replace(/(.{4})/g, '$1 ').trim(),
})
}
+91 -32
View File
@@ -1,64 +1,123 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
import {
verifyPassword,
createSessionToken,
createLoginChallenge,
generateEmailOtp,
hashOtpCode,
otpExpiry,
otpMaxAttempts,
} from '@/lib/auth'
export const dynamic = 'force-dynamic'
const schema = z.object({
password: z.string().min(1),
const loginSchema = z.object({
email: z.string().email().max(200).toLowerCase(),
password: z.string().min(1).max(200),
redirect: z.string().optional(),
})
// POST /api/auth/login
// Simple password-based auth. Password is set via ADMIN_PASSWORD env var.
// Returns a session cookie (HMAC-signed, 7-day expiry).
//
// Step 1 of the login flow. Verifies email + password, then either:
// - 2FA OFF → sets the session cookie immediately, returns { ok, redirect }
// - 2FA ON → issues a short-lived signed challenge and returns
// { twoFactorRequired: true, method, challenge }. For the
// 'email' method an OTP is also sent to the user's inbox.
//
// The challenge is HMAC-signed (5-min TTL) and carries the userId + method.
// Step 2 is POST /api/auth/login/verify with { challenge, code }.
//
// Anti-enumeration: unknown emails / wrong passwords both return the same
// generic "Ungültige Anmeldedaten" so attackers can't probe which emails
// exist. Rate limiting is handled by the global middleware (120 req/min/IP).
export async function POST(req: Request) {
try {
const parsed = schema.safeParse(await req.json())
const parsed = loginSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'invalid request' }, { status: 400 })
return NextResponse.json({ error: 'Ungültige Anmeldedaten' }, { status: 400 })
}
const { password, redirect } = parsed.data
const { email, password, redirect } = parsed.data
const adminPassword = process.env.ADMIN_PASSWORD
if (!adminPassword) {
return NextResponse.json(
{ error: 'ADMIN_PASSWORD env not configured. Set it in Dokploy environment variables.' },
{ status: 503 }
)
const user = await db.user.findUnique({ where: { email } })
// Constant-ish path: if user is null, still run a hash verify against a
// dummy hash so timing doesn't reveal whether the email exists.
const dummyHash = '$scrypt$dummy'
const passwordOk = user?.passwordHash
? verifyPassword(password, user.passwordHash)
: (verifyPassword(password, dummyHash), false)
if (!user || !user.passwordHash || !passwordOk) {
return NextResponse.json({ error: 'Ungültige Anmeldedaten' }, { status: 401 })
}
if (password !== adminPassword) {
return NextResponse.json({ error: 'Falsches Passwort' }, { status: 401 })
// --- 2FA branch ---
if (user.twoFactorEnabled && user.twoFactorMethod) {
const method = user.twoFactorMethod
const challenge = createLoginChallenge(user.id, method)
if (method === 'email') {
// Generate + store an OTP, email it to the user.
const code = generateEmailOtp()
await db.otpCode.create({
data: {
userId: user.id,
codeHash: hashOtpCode(code),
purpose: 'login',
expiresAt: otpExpiry(),
},
})
const { sendEmail } = await import('@/lib/email')
await sendEmail({
to: user.email,
subject: 'AOS — Anmeldecode',
text: `Ihr Anmeldecode lautet: ${code}\n\nDer Code ist 5 Minuten gültig.\n\nWenn Sie sich nicht angemeldet haben, ignorieren Sie diese E-Mail.`,
html: `<p>Ihr Anmeldecode lautet:</p><p style="font-size:28px;font-weight:bold;letter-spacing:8px;text-align:center;background:#f5f5f5;padding:16px;border-radius:8px;">${code}</p><p>Der Code ist 5 Minuten gültig.</p><p style="color:#888;font-size:12px;">Wenn Sie sich nicht angemeldet haben, ignorieren Sie diese E-Mail.</p>`,
}).catch(() => {})
}
return NextResponse.json({
ok: true,
twoFactorRequired: true,
method,
challenge,
// mask email for display
maskedEmail: maskEmail(user.email),
message: method === 'email'
? `Ein 6-stelliger Code wurde an ${maskEmail(user.email)} gesendet.`
: 'Bitte den Code aus Ihrer Authenticator-App eingeben.',
})
}
// Create session token: base64(payload).hmac
const crypto = await import('crypto')
const payload = {
sub: 'admin',
iat: Date.now(),
exp: Date.now() + 7 * 24 * 3600 * 1000, // 7 days
}
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url')
const key = process.env.AUTH_SECRET || adminPassword
const signature = crypto
.createHmac('sha256', key)
.update(payloadB64)
.digest('base64url')
const token = `${payloadB64}.${signature}`
// --- No 2FA → set session immediately ---
await db.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } })
const token = createSessionToken({ sub: user.id, email: user.email, role: user.role })
const res = NextResponse.json({
ok: true,
redirect: redirect || '/',
user: { id: user.id, email: user.email, name: user.name, role: user.role },
})
res.cookies.set('aos-session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 3600, // 7 days
maxAge: 7 * 24 * 3600,
})
return res
} catch {
return NextResponse.json({ error: 'login failed' }, { status: 500 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
function maskEmail(email: string): string {
const [local, domain] = email.split('@')
if (!domain) return email
const visible = local.slice(0, Math.max(1, Math.min(2, local.length - 1)))
return `${visible}${'•'.repeat(Math.max(2, local.length - 2))}@${domain}`
}
+128
View File
@@ -0,0 +1,128 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
import {
verifyLoginChallenge,
createSessionToken,
verifyTotp,
verifyOtpCode,
consumeBackupCode,
hashOtpCode,
otpMaxAttempts,
} from '@/lib/auth'
export const dynamic = 'force-dynamic'
const verifySchema = z.object({
challenge: z.string().min(1),
code: z.string().min(4).max(40),
redirect: z.string().optional(),
})
// POST /api/auth/login/verify
//
// Step 2 of the login flow. Validates the challenge token (issued by step 1)
// and verifies the OTP code:
// - method='totp' → code verified against the stored TOTP secret
// - method='email' → code verified against a stored OtpCode row (hashed)
// - 10-char backup codes are also accepted as a recovery path.
//
// On success: sets the session cookie + updates lastLoginAt. On failure:
// 401 with a remaining-attempts hint (after 5 wrong attempts the latest
// code is invalidated and the user must restart login).
export async function POST(req: Request) {
try {
const parsed = verifySchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'Code fehlt oder ist ungültig.' }, { status: 400 })
}
const { challenge, code, redirect } = parsed.data
const ch = verifyLoginChallenge(challenge)
if (!ch) {
return NextResponse.json({ error: 'Sitzung abgelaufen — bitte erneut anmelden.' }, { status: 401 })
}
const user = await db.user.findUnique({
where: { id: ch.sub },
select: { id: true, email: true, name: true, role: true, twoFactorMethod: true, twoFactorSecret: true, twoFactorBackup: true, twoFactorEnabled: true },
})
if (!user || !user.twoFactorEnabled) {
return NextResponse.json({ error: '2FA nicht aktiv — bitte erneut anmelden.' }, { status: 400 })
}
const normalizedCode = code.trim().replace(/\s/g, '')
// --- Try TOTP ---
if (ch.method === 'totp' && user.twoFactorSecret) {
if (verifyTotp(normalizedCode, user.twoFactorSecret)) {
return await finishLogin(user, redirect)
}
}
// --- Try email OTP (look up the latest unused code for this user+purpose) ---
if (ch.method === 'email') {
const otp = await db.otpCode.findFirst({
where: {
userId: user.id,
purpose: 'login',
consumedAt: null,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: 'desc' },
})
if (!otp) {
return NextResponse.json({ error: 'Kein gültiger Code. Bitte erneut anmelden.' }, { status: 401 })
}
if (otp.attempts >= otpMaxAttempts()) {
await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } })
return NextResponse.json({ error: 'Zu viele Versuche — bitte erneut anmelden.' }, { status: 429 })
}
if (verifyOtpCode(normalizedCode, otp.codeHash)) {
await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } })
return await finishLogin(user, redirect)
}
await db.otpCode.update({ where: { id: otp.id }, data: { attempts: { increment: 1 } } })
const remaining = otpMaxAttempts() - (otp.attempts + 1)
return NextResponse.json(
{ error: 'Falscher Code.', remainingAttempts: Math.max(0, remaining) },
{ status: 401 }
)
}
// --- Try backup code (recovery, 10 chars, format XXXX-XXXX-XX) ---
if (user.twoFactorBackup && normalizedCode.length >= 8) {
const { match, remainingHashed } = consumeBackupCode(normalizedCode, user.twoFactorBackup)
if (match) {
await db.user.update({ where: { id: user.id }, data: { twoFactorBackup: remainingHashed } })
return await finishLogin(user, redirect)
}
}
return NextResponse.json({ error: 'Ungültiger Code.' }, { status: 401 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
async function finishLogin(
user: { id: string; email: string; name: string; role: string },
redirect?: string
) {
await db.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } })
const token = createSessionToken({ sub: user.id, email: user.email, role: user.role })
const res = NextResponse.json({
ok: true,
redirect: redirect || '/',
user: { id: user.id, email: user.email, name: user.name, role: user.role },
})
res.cookies.set('aos-session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 3600,
})
return res
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/session'
export const dynamic = 'force-dynamic'
// GET /api/auth/me
// Returns the currently authenticated user (from the session cookie) or 401.
// Used by the frontend to hydrate the current session + show 2FA status.
export async function GET() {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
emailVerifiedAt: user.emailVerifiedAt,
twoFactorEnabled: user.twoFactorEnabled,
twoFactorMethod: user.twoFactorMethod,
lastLoginAt: user.lastLoginAt,
},
})
}
+106
View File
@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { hashPassword, validatePasswordStrength } from '@/lib/auth'
export const dynamic = 'force-dynamic'
const registerSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email().max(200).toLowerCase(),
password: z.string().min(8).max(200),
})
// POST /api/auth/register
//
// Email + password registration. Creates (or claims) a user account.
//
// Claim flow: if the email already belongs to a seeded User with no
// passwordHash (e.g. demo fixtures), registration sets the password on
// that existing user instead of failing — this lets seeded team members
// claim their accounts.
//
// Role assignment:
// - First account with a password on the system → owner (bootstrap)
// - Everyone else → 'member' (default staff role; can be elevated by an
// owner in settings later).
//
// Email verification: a verification code is emailed if SMTP is configured.
// Until verified, the user can still log in (verification is encouraged but
// not blocking for this single-tenant agency tool). Resend via /verify-email.
export async function POST(req: Request) {
try {
const parsed = registerSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'Angaben ungültig', details: parsed.error.flatten() }, { status: 400 })
}
const { name, email, password } = parsed.data
const pwError = validatePasswordStrength(password)
if (pwError) {
return NextResponse.json({ error: pwError }, { status: 400 })
}
// Check for an existing user with this email that already has a password
// (claimed) — that's a real conflict.
const existing = await db.user.findUnique({ where: { email } })
if (existing?.passwordHash) {
return NextResponse.json(
{ error: 'Für diese E-Mail existiert bereits ein Konto. Bitte anmelden.' },
{ status: 409 }
)
}
const passwordHash = hashPassword(password)
// Decide the role: first account with a password becomes the owner.
const usersWithPassword = await db.user.count({ where: { NOT: { passwordHash: null } } })
const role = usersWithPassword === 0 ? 'owner' : 'member'
let user
if (existing) {
// Claim the seeded account (keep id, role may be elevated if first).
user = await db.user.update({
where: { id: existing.id },
data: { name, passwordHash, role: usersWithPassword === 0 ? 'owner' : existing.role },
})
} else {
user = await db.user.create({
data: { name, email, passwordHash, role },
})
}
// Send a verification email (best-effort; not blocking).
const { sendEmail } = await import('@/lib/email')
const { generateEmailOtp, hashOtpCode, otpExpiry } = await import('@/lib/auth')
const code = generateEmailOtp()
await db.otpCode.create({
data: {
userId: user.id,
codeHash: hashOtpCode(code),
purpose: 'email-verify',
expiresAt: otpExpiry(),
},
})
await sendEmail({
to: email,
subject: 'E-Mail bestätigen — AOS',
text: `Willkommen bei AOS!\n\nIhr Bestätigungscode lautet: ${code}\n\nDer Code ist 5 Minuten gültig.\n\nMit freundlichen Grüßen\nDFM Solutions`,
html: `<h2>Willkommen bei AOS!</h2><p>Ihr Bestätigungscode lautet:</p><p style="font-size:28px;font-weight:bold;letter-spacing:8px;text-align:center;background:#f5f5f5;padding:16px;border-radius:8px;">${code}</p><p>Der Code ist 5 Minuten gültig.</p><p>Mit freundlichen Grüßen<br>DFM Solutions</p>`,
}).catch(() => {})
// Don't auto-login after registration: require explicit login so the
// user learns the flow (and enters 2FA if they later enable it).
return NextResponse.json({
ok: true,
message: 'Konto erstellt. Bitte melden Sie sich an.',
role,
emailVerificationSent: true,
// If this is the very first owner, flag it so the UI can show guidance.
isFirstOwner: role === 'owner',
}, { status: 201 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
import {
verifyLoginChallenge,
generateEmailOtp,
hashOtpCode,
otpExpiry,
} from '@/lib/auth'
export const dynamic = 'force-dynamic'
const schema = z.object({
challenge: z.string().min(1),
})
// POST /api/auth/resend-otp
//
// Re-issues an email OTP for an in-progress 2FA login. The challenge token
// from step 1 is required — this ensures only users who already proved
// their password can request a new code (no anonymous spam).
//
// Cooldown: at most 1 resend per 30s (enforced by checking the latest code's
// createdAt). Prevents email bombing.
export async function POST(req: Request) {
try {
const parsed = schema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'Ungültige Anfrage' }, { status: 400 })
}
const { challenge } = parsed.data
const ch = verifyLoginChallenge(challenge)
if (!ch || ch.method !== 'email') {
return NextResponse.json({ error: 'Ungültige oder abgelaufene Sitzung.' }, { status: 401 })
}
// Cooldown: reject if the latest code is < 30s old.
const latest = await db.otpCode.findFirst({
where: { userId: ch.sub, purpose: 'login' },
orderBy: { createdAt: 'desc' },
})
if (latest && Date.now() - latest.createdAt.getTime() < 30_000) {
return NextResponse.json(
{ error: 'Bitte 30 Sekunden warten, bevor Sie einen neuen Code anfordern.', cooldown: true },
{ status: 429 }
)
}
const code = generateEmailOtp()
await db.otpCode.create({
data: {
userId: ch.sub,
codeHash: hashOtpCode(code),
purpose: 'login',
expiresAt: otpExpiry(),
},
})
const user = await db.user.findUnique({ where: { id: ch.sub }, select: { email: true } })
if (user) {
const { sendEmail } = await import('@/lib/email')
await sendEmail({
to: user.email,
subject: 'AOS — Neuer Anmeldecode',
text: `Ihr neuer Anmeldecode lautet: ${code}\n\nDer Code ist 5 Minuten gültig.`,
html: `<p>Ihr neuer Anmeldecode lautet:</p><p style="font-size:28px;font-weight:bold;letter-spacing:8px;text-align:center;background:#f5f5f5;padding:16px;border-radius:8px;">${code}</p><p>Der Code ist 5 Minuten gültig.</p>`,
}).catch(() => {})
}
return NextResponse.json({ ok: true, message: 'Neuer Code gesendet.' })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+77
View File
@@ -0,0 +1,77 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { verifyOtpCode, otpMaxAttempts } from '@/lib/auth'
import { getCurrentUser } from '@/lib/session'
export const dynamic = 'force-dynamic'
const schema = z.object({
code: z.string().regex(/^\d{6}$/, 'Code muss 6 Ziffern sein.'),
})
// POST /api/auth/verify-email
//
// Verifies the email ownership OTP sent at registration. Marks the user's
// emailVerifiedAt. Can be called by:
// - an authenticated user verifying their own email, OR
// - an unauthenticated user with just the code (if they haven't logged in
// yet). In that case we look up by the most recent unconsumed code.
//
// Codes are single-use, 5-min TTL, max 5 attempts.
export async function POST(req: Request) {
try {
const parsed = schema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'Ungültiger Code.' }, { status: 400 })
}
const { code } = parsed.data
// Try the authenticated user first; fall back to the latest unconsumed code.
const me = await getCurrentUser()
let userId = me?.id
let otp
if (userId) {
otp = await db.otpCode.findFirst({
where: { userId, purpose: 'email-verify', consumedAt: null, expiresAt: { gt: new Date() } },
orderBy: { createdAt: 'desc' },
})
} else {
// Find the most recent unconsumed email-verify code matching this code.
// This is a single-tenant tool with low volume — acceptable.
const candidates = await db.otpCode.findMany({
where: { purpose: 'email-verify', consumedAt: null, expiresAt: { gt: new Date() } },
orderBy: { createdAt: 'desc' },
take: 20,
})
otp = candidates.find((c) => verifyOtpCode(code, c.codeHash)) ?? null
if (otp) userId = otp.userId
}
if (!otp || !userId) {
return NextResponse.json({ error: 'Kein gültiger Code gefunden — bitte neu anfordern.' }, { status: 404 })
}
if (otp.attempts >= otpMaxAttempts()) {
await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } })
return NextResponse.json({ error: 'Zu viele Versuche — bitte neuen Code anfordern.' }, { status: 429 })
}
if (!verifyOtpCode(code, otp.codeHash)) {
await db.otpCode.update({ where: { id: otp.id }, data: { attempts: { increment: 1 } } })
const remaining = otpMaxAttempts() - (otp.attempts + 1)
return NextResponse.json(
{ error: 'Falscher Code.', remainingAttempts: Math.max(0, remaining) },
{ status: 401 }
)
}
await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } })
await db.user.update({ where: { id: userId }, data: { emailVerifiedAt: new Date() } })
return NextResponse.json({ ok: true, message: 'E-Mail bestätigt.' })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+179 -20
View File
@@ -2,65 +2,224 @@
import { Suspense, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Loader2, Lock } from 'lucide-react'
import { Loader2, Lock, Mail, ShieldCheck, ArrowLeft, RotateCw } from 'lucide-react'
import { toast } from 'sonner'
type Step =
| { kind: 'credentials' }
| { kind: 'otp'; method: string; challenge: string; maskedEmail: string }
function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const redirect = searchParams.get('redirect') || '/'
const registered = searchParams.get('registered') === '1'
const [step, setStep] = useState<Step>({ kind: 'credentials' })
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [resendCooldown, setResendCooldown] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
// --- Step 1: email + password ---
const submitCredentials = async (e: React.FormEvent) => {
e.preventDefault()
if (!password) return
if (!email || !password) return
setLoading(true)
setError('')
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password, redirect }),
body: JSON.stringify({ email: email.toLowerCase(), password, redirect }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Login fehlgeschlagen')
return
}
router.push(data.redirect || '/')
router.refresh()
if (data.twoFactorRequired) {
setStep({
kind: 'otp',
method: data.method,
challenge: data.challenge,
maskedEmail: data.maskedEmail,
})
if (data.message) toast.info(data.message)
} else {
router.push(data.redirect || '/')
router.refresh()
}
} catch {
setError('Netzwerkfehler')
setError('Netzwerkfehler — bitte erneut versuchen.')
} finally {
setLoading(false)
}
}
// --- Step 2: OTP code ---
const submitOtp = async (e: React.FormEvent) => {
e.preventDefault()
if (step.kind !== 'otp' || !code) return
setLoading(true)
setError('')
try {
const res = await fetch('/api/auth/login/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ challenge: step.challenge, code, redirect }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Code ungültig')
if (data.remainingAttempts !== undefined) {
setError(`${data.error || 'Code ungültig'} — ${data.remainingAttempts} Versuche übrig.`)
}
return
}
router.push(data.redirect || '/')
router.refresh()
} catch {
setError('Netzwerkfehler — bitte erneut versuchen.')
} finally {
setLoading(false)
}
}
const resendOtp = async () => {
if (step.kind !== 'otp') return
setResendCooldown(true)
const t = toast.loading('Neuen Code anfordern…')
try {
const res = await fetch('/api/auth/resend-otp', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ challenge: step.challenge }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehlgeschlagen')
toast.success('Neuer Code gesendet.', { id: t })
// 30s cooldown to match backend.
setTimeout(() => setResendCooldown(false), 30_000)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
setResendCooldown(false)
}
}
if (step.kind === 'otp') {
return (
<form onSubmit={submitOtp} className="space-y-4">
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 p-3 text-xs">
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">
{step.method === 'totp'
? '2-Faktor-Authentifizierung: Code aus Ihrer Authenticator-App eingeben.'
: `Ein 6-stelliger Code wurde an ${step.maskedEmail} gesendet.`}
</span>
</div>
<div className="space-y-2">
<Label htmlFor="code">
{step.method === 'totp' ? 'Authenticator-Code' : 'Bestätigungscode'}
</Label>
<Input
id="code"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, step.method === 'totp' ? 6 : 6))}
placeholder="000000"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
autoFocus
className="text-center text-lg tracking-[0.4em]"
/>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<Button type="submit" className="w-full gap-2" disabled={loading || code.length < 4}>
{loading && <Loader2 className="size-4 animate-spin" />}
Bestätigen
</Button>
<div className="flex items-center justify-between text-xs">
<button
type="button"
onClick={() => { setStep({ kind: 'credentials' }); setCode(''); setError('') }}
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3" /> Zurück
</button>
{step.method === 'email' && (
<button
type="button"
onClick={resendOtp}
disabled={resendCooldown}
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
<RotateCw className="size-3" /> {resendCooldown ? 'Code gesendet…' : 'Neuen Code'}
</button>
)}
</div>
<p className="text-center text-[11px] text-muted-foreground">
Backup-Code vergessen? Geben Sie einen 10-stelligen Backup-Code ein (Format: XXXX-XXXX-XX).
</p>
</form>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={submitCredentials} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@firma.de"
autoComplete="email"
autoFocus
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Admin-Passwort eingeben"
autoFocus
autoComplete="current-password"
/>
<div className="relative">
<Lock className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
className="pl-9"
/>
</div>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
{registered && (
<p className="rounded-md border border-foreground/20 bg-foreground/5 p-2 text-center text-xs text-muted-foreground">
Konto erstellt. Ein Bestätigungscode wurde an Ihre E-Mail gesendet — bitte anmelden.
</p>
)}
<Button type="submit" className="w-full gap-2" disabled={loading || !password}>
<Button type="submit" className="w-full gap-2" disabled={loading || !email || !password}>
{loading && <Loader2 className="size-4 animate-spin" />}
Anmelden
</Button>
<p className="text-center text-sm text-muted-foreground">
Noch kein Konto?{' '}
<Link href="/register" className="font-medium text-foreground hover:underline">
Konto erstellen
</Link>
</p>
</form>
)
}
+169
View File
@@ -0,0 +1,169 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Loader2, Lock, Mail, User, ShieldCheck } from 'lucide-react'
export default function RegisterPage() {
const router = useRouter()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const pwRules = [
{ ok: password.length >= 8, label: 'Mind. 8 Zeichen' },
{ ok: /[a-z]/.test(password), label: 'Kleinbuchstabe' },
{ ok: /[A-Z]/.test(password), label: 'Großbuchstabe' },
{ ok: /[0-9]/.test(password), label: 'Zahl' },
]
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name || !email || !password) return
if (password !== confirm) {
setError('Passwörter stimmen nicht überein.')
return
}
setLoading(true)
setError('')
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, email: email.toLowerCase(), password }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Registrierung fehlgeschlagen')
return
}
// Registration successful → go to login.
router.push('/login?registered=1')
router.refresh()
} catch {
setError('Netzwerkfehler — bitte erneut versuchen.')
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center space-y-2">
<div className="mx-auto flex size-14 items-center justify-center rounded-xl bg-foreground text-background">
<User className="size-7" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">Konto erstellen</h1>
<p className="text-sm text-muted-foreground">
Zugang zum AOS — dfmpanel.eu
</p>
</div>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Max Mustermann"
autoComplete="name"
autoFocus
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@firma.de"
autoComplete="email"
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
className="pl-9"
/>
</div>
{password && (
<div className="flex flex-wrap gap-1.5 pt-1">
{pwRules.map((r) => (
<span
key={r.label}
className={
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ' +
(r.ok
? 'bg-foreground/10 text-foreground'
: 'bg-muted text-muted-foreground')
}
>
{r.ok ? <ShieldCheck className="size-2.5" /> : null}
{r.label}
</span>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Passwort wiederholen</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
className="pl-9"
/>
</div>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<Button
type="submit"
className="w-full gap-2"
disabled={loading || !name || !email || !password || !confirm || !pwRules.every((r) => r.ok)}
>
{loading && <Loader2 className="size-4 animate-spin" />}
Konto erstellen
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Bereits ein Konto?{' '}
<Link href="/login" className="font-medium text-foreground hover:underline">
Anmelden
</Link>
</p>
</div>
</div>
)
}
+13 -4
View File
@@ -2,6 +2,7 @@
import { Menu, Search, Bell, ChevronRight } from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import { useFetch } from '@/hooks/use-fetch'
import { ThemeToggle } from '@/components/theme-toggle'
import { LocaleSwitcher } from './locale-switcher'
import { NotificationsBell } from './notifications-bell'
@@ -51,6 +52,14 @@ export function TopBar() {
const { view, setSidebarOpen, setCommandOpen } = useAos()
const meta = TITLES[view]
const crumbParts = meta.crumb.split(' / ')
const { data: meData } = useFetch<{ user: { name: string; email: string; role: string } } | null>('/api/auth/me')
const user = meData?.user
const initials = (user?.name ?? '?')
.split(' ')
.map((p) => p[0])
.slice(0, 2)
.join('')
.toUpperCase()
return (
<header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-md md:px-6">
@@ -95,19 +104,19 @@ export function TopBar() {
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 px-1.5">
<Avatar className="size-8">
<AvatarFallback className="bg-primary text-primary-foreground text-xs">MB</AvatarFallback>
<AvatarFallback className="bg-primary text-primary-foreground text-xs">{initials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="flex flex-col">
<span className="text-sm font-medium">Max Brandt</span>
<span className="text-xs text-muted-foreground">max@nordlicht.digital</span>
<span className="text-sm font-medium">{user?.name ?? '—'}</span>
<span className="text-xs text-muted-foreground">{user?.email ?? ''}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="flex items-center justify-between">
<span>Rolle</span>
<Badge variant="outline" className="text-[10px]">Owner</Badge>
<Badge variant="outline" className="text-[10px] capitalize">{user?.role ?? '—'}</Badge>
</DropdownMenuItem>
<DropdownMenuItem>Profil</DropdownMenuItem>
<DropdownMenuItem>Sitzungen</DropdownMenuItem>
+397
View File
@@ -0,0 +1,397 @@
'use client'
import { useState } from 'react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
ShieldCheck,
Smartphone,
Mail,
Loader2,
Lock,
Copy,
Check,
AlertTriangle,
KeyRound,
} from 'lucide-react'
import { toast } from 'sonner'
interface MeResponse {
user: {
id: string
email: string
name: string
role: string
emailVerifiedAt: string | null
twoFactorEnabled: boolean
twoFactorMethod: string | null
lastLoginAt: string | null
}
}
type EnableStep =
| { kind: 'choose-method' }
| { kind: 'totp-setup'; secret: string; qr: string; otpauthUrl: string }
| { kind: 'enter-code'; method: 'totp' | 'email'; secret?: string }
| { kind: 'backup-codes'; codes: string[] }
/**
* TwoFactorSettings — self-contained 2FA management card.
*
* States:
* - 2FA OFF → "Enable" button → method chooser (Email / Authenticator)
* - Email: re-auth (password) → enable → show backup codes
* - TOTP: setup (QR + secret) → enter code from app → re-auth → enable → backup codes
* - 2FA ON → show method + status → "Disable" (re-auth required)
*
* Fetches /api/auth/me for the current status. Uses:
* POST /api/auth/2fa/setup → TOTP provisioning
* POST /api/auth/2fa/enable → finalize (re-auth + verify)
* POST /api/auth/2fa/disable → turn off (re-auth required)
*/
export function TwoFactorSettings() {
const { data, loading, refetch } = useFetch<MeResponse>('/api/auth/me')
const [enableOpen, setEnableOpen] = useState(false)
const [disableOpen, setDisableOpen] = useState(false)
const [step, setStep] = useState<EnableStep>({ kind: 'choose-method' })
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [copied, setCopied] = useState<string | null>(null)
const user = data?.user
const reset = () => {
setStep({ kind: 'choose-method' })
setPassword('')
setCode('')
setBusy(false)
}
// --- TOTP setup: get QR + secret ---
const startTotp = async () => {
setBusy(true)
const t = toast.loading('QR-Code wird generiert…')
try {
const r = await fetch('/api/auth/2fa/setup', { method: 'POST' })
const res = await r.json()
if (!r.ok) throw new Error(res.error || 'Setup fehlgeschlagen')
setStep({ kind: 'totp-setup', secret: res.secret, qr: res.qr, otpauthUrl: res.otpauthUrl })
toast.success('QR-Code bereit. Mit Authenticator-App scannen.', { id: t })
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
} finally {
setBusy(false)
}
}
// --- Enable 2FA (re-auth + verify code) ---
const enable = async () => {
if (!password) return
if (step.kind === 'enter-code' && !code) return
setBusy(true)
const t = toast.loading('2FA wird aktiviert…')
try {
const method = step.kind === 'enter-code' ? step.method : 'email'
const body: Record<string, string> = { method, password }
if (step.kind === 'enter-code' && step.method === 'totp') {
body.code = code
body.secret = step.secret ?? ''
}
const r = await fetch('/api/auth/2fa/enable', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
const res = await r.json()
if (!r.ok) throw new Error(res.error || 'Aktivierung fehlgeschlagen')
toast.success(res.message ?? '2FA aktiviert', { id: t, duration: 6000 })
if (res.backupCodes?.length) {
setStep({ kind: 'backup-codes', codes: res.backupCodes })
} else {
setEnableOpen(false)
reset()
refetch()
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
} finally {
setBusy(false)
}
}
// --- Disable 2FA ---
const disable = async () => {
if (!password) return
setBusy(true)
const t = toast.loading('2FA wird deaktiviert…')
try {
const r = await fetch('/api/auth/2fa/disable', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password }),
})
const res = await r.json()
if (!r.ok) throw new Error(res.error || 'Deaktivierung fehlgeschlagen')
toast.success(res.message ?? '2FA deaktiviert', { id: t })
setDisableOpen(false)
setPassword('')
refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
} finally {
setBusy(false)
}
}
const copy = (text: string, label: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(label)
setTimeout(() => setCopied(null), 2000)
toast.success(`${label} kopiert`)
})
}
const enabled = user?.twoFactorEnabled
const method = user?.twoFactorMethod
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<ShieldCheck className="size-3.5 text-muted-foreground" />
Zwei-Faktor-Authentifizierung (2FA)
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-4">
{loading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Laden…
</div>
) : !user ? (
<p className="text-xs text-muted-foreground">Nicht angemeldet.</p>
) : enabled ? (
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1 text-[10px] text-foreground">
<Check className="size-3" /> Aktiv
</Badge>
<span className="text-xs text-muted-foreground">
{method === 'totp' ? (
<span className="inline-flex items-center gap-1"><Smartphone className="size-3" /> Authenticator-App</span>
) : method === 'email' ? (
<span className="inline-flex items-center gap-1"><Mail className="size-3" /> E-Mail-OTP</span>
) : method}
</span>
</div>
<Button size="sm" variant="outline" className="gap-1.5 text-xs" onClick={() => { setDisableOpen(true); setPassword('') }}>
<Lock className="size-3" /> Deaktivieren
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Beim Anmelden wird zusätzlich zum Passwort ein einmaliger Code benötigt. Backup-Codes wurden bei der Einrichtung angezeigt — sicher aufbewahren.
</p>
</>
) : (
<>
<p className="text-xs text-muted-foreground">
Schützen Sie Ihr Konto mit einem zweiten Faktor. Optional — kann jederzeit aktiviert oder deaktiviert werden.
</p>
<Button size="sm" className="gap-1.5" onClick={() => { setEnableOpen(true); reset() }}>
<ShieldCheck className="size-3.5" /> 2FA aktivieren
</Button>
</>
)}
</CardContent>
{/* --- Enable flow --- */}
<Dialog open={enableOpen} onOpenChange={(o) => { setEnableOpen(o); if (!o) reset() }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="size-4 text-muted-foreground" />
2FA aktivieren
</DialogTitle>
<DialogDescription>
{step.kind === 'backup-codes'
? 'Backup-Codes — sicher aufbewahren (einmalig sichtbar).'
: 'Wählen Sie eine Methode und bestätigen Sie mit Ihrem Passwort.'}
</DialogDescription>
</DialogHeader>
{step.kind === 'choose-method' && (
<div className="space-y-2">
<button
type="button"
disabled={busy}
onClick={() => setStep({ kind: 'enter-code', method: 'email' })}
className="flex w-full items-center gap-3 rounded-md border border-border p-3 text-left transition hover:bg-muted/40 disabled:opacity-50"
>
<Mail className="size-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">E-Mail-OTP</p>
<p className="text-[11px] text-muted-foreground">Code pro Anmeldung an Ihre E-Mail gesendet.</p>
</div>
</button>
<button
type="button"
disabled={busy}
onClick={startTotp}
className="flex w-full items-center gap-3 rounded-md border border-border p-3 text-left transition hover:bg-muted/40 disabled:opacity-50"
>
<Smartphone className="size-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Authenticator-App</p>
<p className="text-[11px] text-muted-foreground">Google Authenticator, Authy, 1Password (TOTP).</p>
</div>
</button>
</div>
)}
{step.kind === 'totp-setup' && (
<div className="space-y-3">
<div className="flex flex-col items-center gap-2">
{/* QR code as data URI — no next/image optimization needed */}
<img src={step.qr} alt="QR-Code für Authenticator" className="rounded-md border border-border" width={200} height={200} />
<p className="text-center text-[11px] text-muted-foreground">
Scannen Sie den QR-Code mit Ihrer Authenticator-App.
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Oder manuell eingeben:</Label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md bg-muted px-2 py-1.5 font-mono text-[11px] break-all">
{step.secret}
</code>
<Button size="sm" variant="ghost" className="shrink-0" onClick={() => copy(step.secret, 'Secret')}>
{copied === 'Secret' ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</Button>
</div>
</div>
<Button className="w-full" onClick={() => setStep({ kind: 'enter-code', method: 'totp', secret: step.secret })}>
Weiter — Code eingeben
</Button>
</div>
)}
{step.kind === 'enter-code' && (
<div className="space-y-3">
{step.method === 'totp' && (
<div className="space-y-1.5">
<Label htmlFor="code" className="text-xs">Code aus der Authenticator-App</Label>
<Input
id="code"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
inputMode="numeric"
className="text-center text-lg tracking-[0.4em]"
autoFocus
/>
</div>
)}
{step.method === 'email' && (
<p className="rounded-md border border-border bg-muted/40 p-2.5 text-[11px] text-muted-foreground">
Beim Aktivieren wird ein Bestätigungscode an Ihre E-Mail gesendet. Geben Sie unten Ihr Passwort ein, um die Einrichtung abzuschließen.
</p>
)}
<div className="space-y-1.5">
<Label htmlFor="enable-pw" className="text-xs">Passwort zur Bestätigung</Label>
<Input
id="enable-pw"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
/>
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setEnableOpen(false)}>Abbrechen</Button>
<Button size="sm" disabled={busy || !password || (step.method === 'totp' && code.length < 6)} onClick={enable} className="gap-1.5">
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ShieldCheck className="size-3.5" />}
2FA aktivieren
</Button>
</DialogFooter>
</div>
)}
{step.kind === 'backup-codes' && (
<div className="space-y-3">
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-[11px] text-muted-foreground">
<p className="flex items-center gap-1.5 font-medium text-foreground">
<AlertTriangle className="size-3" />
Diese Codes sind nur jetzt sichtbar.
</p>
<p className="mt-1">Bei Verlust Ihres zweiten Faktors erlaubt jeder Code eine Anmeldung. Sicher aufbewahren (z.B. Passwort-Manager oder Ausdruck im Tresor).</p>
</div>
<div className="grid grid-cols-2 gap-1.5">
{step.codes.map((c) => (
<code key={c} className="rounded bg-muted px-2 py-1.5 text-center font-mono text-sm">{c}</code>
))}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => copy(step.codes.join('\n'), 'Backup-Codes')}>
{copied === 'Backup-Codes' ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
Alle kopieren
</Button>
<Button size="sm" className="ml-auto" onClick={() => { setEnableOpen(false); reset(); refetch() }}>
<KeyRound className="size-3.5" /> Fertig
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* --- Disable flow --- */}
<Dialog open={disableOpen} onOpenChange={(o) => { setDisableOpen(o); if (!o) setPassword('') }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="size-4 text-muted-foreground" />
2FA deaktivieren
</DialogTitle>
<DialogDescription>
Ihr Konto ist dann nur noch durch das Passwort geschützt. Bitte Passwort zur Bestätigung eingeben.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="disable-pw" className="text-xs">Passwort</Label>
<Input
id="disable-pw"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
autoFocus
/>
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setDisableOpen(false)}>Abbrechen</Button>
<Button size="sm" disabled={busy || !password} onClick={disable} className="gap-1.5 bg-foreground text-background hover:bg-foreground/90">
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />}
Deaktivieren
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
</Card>
)
}
+4
View File
@@ -19,6 +19,7 @@ import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { TwoFactorSettings } from '../two-factor-settings'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@@ -198,6 +199,9 @@ export function SettingsView() {
</Card>
</div>
{/* Account security — 2FA */}
<TwoFactorSettings />
{/* Agency settings preview */}
<Card>
<CardHeader className="pb-2">
+262
View File
@@ -0,0 +1,262 @@
import { scryptSync, randomBytes, timingSafeEqual, createHash, randomUUID, createHmac } from 'crypto'
import { generateSecret, verifySync, generateURI } from 'otplib'
/**
* Password hashing — uses Node's built-in scrypt (no external dependency).
* Format stored in DB: "<saltHex>:<hashHex>" (both 32 bytes).
*
* scrypt parameters (N=16384, r=8, p=1) match OWASP 2023 recommendations
* and run in ~80ms — acceptable for interactive login.
*/
const SALT_BYTES = 16
const KEY_LEN = 32
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }
export function hashPassword(password: string): string {
const salt = randomBytes(SALT_BYTES)
const hash = scryptSync(password, salt, KEY_LEN, SCRYPT_PARAMS)
return `${salt.toString('hex')}:${hash.toString('hex')}`
}
export function verifyPassword(password: string, stored: string): boolean {
try {
const [saltHex, hashHex] = stored.split(':')
if (!saltHex || !hashHex) return false
const salt = Buffer.from(saltHex, 'hex')
const expected = Buffer.from(hashHex, 'hex')
const actual = scryptSync(password, salt, KEY_LEN, SCRYPT_PARAMS)
if (actual.length !== expected.length) return false
return timingSafeEqual(actual, expected)
} catch {
return false
}
}
/**
* Password policy — enforce on registration / password change.
*/
export function validatePasswordStrength(password: string): string | null {
if (password.length < 8) return 'Passwort muss mindestens 8 Zeichen lang sein.'
if (password.length > 200) return 'Passwort ist zu lang.'
if (!/[a-z]/.test(password)) return 'Passwort muss einen Kleinbuchstaben enthalten.'
if (!/[A-Z]/.test(password)) return 'Passwort muss einen Großbuchstaben enthalten.'
if (!/[0-9]/.test(password)) return 'Passwort muss eine Zahl enthalten.'
return null
}
/**
* TOTP — Time-based One-Time Password (RFC 6238).
* Compatible with Google Authenticator, Authy, 1Password, Microsoft Authenticator.
*
* otplib v13 API (async-first, but we use the *Sync variants for
* request-handler simplicity — TOTP computation is fast/synchronous).
*
* epochTolerance: 1 → ±1 step (±30s) to tolerate clock drift.
*/
const TOTP_OPTS = {
strategy: 'totp' as const,
period: 30,
digits: 6,
epochTolerance: 1,
}
export function generateTotpSecret(): string {
return generateSecret({ length: 20 })
}
export function totpOtpauthUrl(opts: {
secret: string
label: string
issuer?: string
}): string {
return generateURI({
issuer: opts.issuer ?? 'AOS',
label: opts.label,
secret: opts.secret,
strategy: 'totp',
period: 30,
digits: 6,
})
}
export function verifyTotp(token: string, secret: string): boolean {
try {
return verifySync({
...TOTP_OPTS,
secret,
token: token.replace(/\s/g, ''),
})
} catch {
return false
}
}
/**
* Email OTP — 6-digit numeric codes. We store only the SHA-256 hash in the DB
* (never the code itself). Codes are single-use and expire after 5 minutes.
*/
export function generateEmailOtp(): string {
// 6-digit code with leading zeros preserved.
const n = randomBytes(4).readUInt32BE(0) % 1000000
return String(n).padStart(6, '0')
}
export function hashOtpCode(code: string): string {
return createHash('sha256').update(code).digest('hex')
}
export function verifyOtpCode(code: string, codeHash: string): boolean {
const actual = hashOtpCode(code)
const expected = Buffer.from(codeHash, 'hex')
const actualBuf = Buffer.from(actual, 'hex')
if (actualBuf.length !== expected.length) return false
return timingSafeEqual(actualBuf, expected)
}
const OTP_TTL_MS = 5 * 60 * 1000 // 5 minutes
const OTP_MAX_ATTEMPTS = 5
export function otpExpiry(): Date {
return new Date(Date.now() + OTP_TTL_MS)
}
export function otpMaxAttempts(): number {
return OTP_MAX_ATTEMPTS
}
/**
* Backup codes — 8 single-use recovery codes, stored hashed. Shown once to the
* user when 2FA is enabled. Each code: 10 chars, grouped XXXX-XXXX-XX.
*/
export function generateBackupCodes(count = 8): { plain: string[]; hashed: string } {
const plain: string[] = []
const hashed: string[] = []
for (let i = 0; i < count; i++) {
const raw = randomBytes(6).toString('base64url').replace(/[-_]/g, '').slice(0, 10).toUpperCase()
const formatted = `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 10)}`
plain.push(formatted)
hashed.push(hashOtpCode(formatted))
}
return { plain, hashed: hashed.join('\n') }
}
export function consumeBackupCode(
submitted: string,
storedHashed: string
): { match: boolean; remainingHashed: string } {
const hashed = storedHashed.split('\n').filter(Boolean)
const submittedHash = hashOtpCode(submitted.trim().toUpperCase())
const idx = hashed.findIndex((h) => {
try {
return timingSafeEqual(Buffer.from(h, 'hex'), Buffer.from(submittedHash, 'hex'))
} catch {
return false
}
})
if (idx === -1) return { match: false, remainingHashed: storedHashed }
const remaining = hashed.filter((_, i) => i !== idx)
return { match: true, remainingHashed: remaining.join('\n') }
}
/**
* Session token — HMAC-signed JWT-like token.
* Format: base64url(payload).hmac
* The signing key is AUTH_SECRET (required). Falls back to a random ephemeral
* key only in dev when AUTH_SECRET is unset (sessions won't survive restart).
*/
export interface SessionPayload {
sub: string // user id
email: string
role: string
iat: number
exp: number // ms epoch
}
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
function getSessionKey(): string {
const key = process.env.AUTH_SECRET
if (!key) {
// Dev fallback — warn so production sets AUTH_SECRET.
if (process.env.NODE_ENV === 'production') {
throw new Error('AUTH_SECRET must be set in production')
}
// Stable per-process fallback (sessions valid until server restart).
return 'dev-insecure-secret-' + process.pid
}
return key
}
export function createSessionToken(payload: Omit<SessionPayload, 'iat' | 'exp'>): string {
const full: SessionPayload = {
...payload,
iat: Date.now(),
exp: Date.now() + SESSION_TTL_MS,
}
const payloadB64 = Buffer.from(JSON.stringify(full)).toString('base64url')
const sig = createHmac('sha256', getSessionKey()).update(payloadB64).digest('base64url')
return `${payloadB64}.${sig}`
}
export function verifySessionToken(token: string | undefined): SessionPayload | null {
if (!token) return null
const [payloadB64, sig] = token.split('.')
if (!payloadB64 || !sig) return null
try {
const expectedSig = createHmac('sha256', getSessionKey()).update(payloadB64).digest('base64url')
const a = Buffer.from(sig, 'base64url')
const b = Buffer.from(expectedSig, 'base64url')
if (a.length !== b.length || !timingSafeEqual(a, b)) return null
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()) as SessionPayload
if (payload.exp && Date.now() > payload.exp) return null
return payload
} catch {
return null
}
}
/**
* Login challenge — short-lived signed token returned from login step 1.
* Carries the userId + method so step 2 (/verify) can issue the session
* without re-sending the password. Expires in 5 minutes.
*
* Format: base64url({ sub, method, jti, exp }).hmac
*/
export function createLoginChallenge(userId: string, method: string): string {
const payload = {
sub: userId,
method,
jti: randomUUID(),
exp: Date.now() + OTP_TTL_MS,
}
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url')
const sig = createHmac('sha256', getSessionKey()).update(payloadB64).digest('base64url')
return `${payloadB64}.${sig}`
}
export interface LoginChallenge {
sub: string
method: string
jti: string
exp: number
}
export function verifyLoginChallenge(token: string): LoginChallenge | null {
if (!token) return null
const [payloadB64, sig] = token.split('.')
if (!payloadB64 || !sig) return null
try {
const expectedSig = createHmac('sha256', getSessionKey()).update(payloadB64).digest('base64url')
const a = Buffer.from(sig, 'base64url')
const b = Buffer.from(expectedSig, 'base64url')
if (a.length !== b.length || !timingSafeEqual(a, b)) return null
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()) as LoginChallenge
if (payload.exp && Date.now() > payload.exp) return null
return payload
} catch {
return null
}
}
+47
View File
@@ -0,0 +1,47 @@
import { cookies } from 'next/headers'
import { db } from '@/lib/db'
import { verifySessionToken, type SessionPayload } from '@/lib/auth'
/**
* Session helpers for server components / route handlers.
* Reads the `aos-session` cookie, verifies the HMAC, and resolves the User.
*/
export async function getSession(): Promise<SessionPayload | null> {
const store = await cookies()
const token = store.get('aos-session')?.value
return verifySessionToken(token)
}
export async function getCurrentUser() {
const session = await getSession()
if (!session) return null
const user = await db.user.findUnique({
where: { id: session.sub },
select: {
id: true,
email: true,
name: true,
role: true,
emailVerifiedAt: true,
twoFactorEnabled: true,
twoFactorMethod: true,
lastLoginAt: true,
},
})
return user
}
export async function requireUser() {
const user = await getCurrentUser()
if (!user) {
throw new Error('UNAUTHORIZED')
}
return user
}
/** Actor string for AuditLog entries — identifies the authenticated user. */
export async function getActor(): Promise<string> {
const user = await getCurrentUser()
return user ? `user:${user.email}` : 'anonymous'
}
+83 -73
View File
@@ -1,8 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
// Middleware: protects all routes except public ones.
// Uses a simple session cookie (HMAC-signed) for auth.
// Password is set via ADMIN_PASSWORD env var.
//
// Session = HMAC-signed token (base64url(payload).hmac) issued by the login
// flow. The signing key is AUTH_SECRET (required). Verified here with a
// constant-time HMAC comparison. The payload carries { sub, email, role,
// iat, exp } with a 7-day TTL.
//
// Auth flow (email + password, optional 2FA):
// /login — email + password step (public)
// /login/verify — 2FA OTP step (public, uses signed challenge)
// /register — create account (public)
// /verify-email — confirm email OTP (public)
// /resend-otp — re-send login OTP (public, challenge-gated)
// /2fa/* — manage 2FA (session-protected)
// /me — current user (session-protected)
//
// Also applies global rate limiting to API routes.
// --- Simple in-memory rate limiter (per-IP, per-minute) ---
@@ -35,7 +48,11 @@ const PUBLIC_PATHS = [
'/api/aos/licenses/validate',
'/api/aos/stripe/webhook',
'/api/auth/login',
'/api/auth/register',
'/api/auth/verify-email',
'/api/auth/resend-otp',
'/login',
'/register',
'/_next',
'/favicon.ico',
'/manifest.json',
@@ -50,6 +67,10 @@ const PUBLIC_API_PREFIXES = [
'/api/aos/licenses/validate',
'/api/aos/stripe/webhook',
'/api/auth/login',
'/api/auth/login/verify',
'/api/auth/register',
'/api/auth/verify-email',
'/api/auth/resend-otp',
'/api/v1/', // public REST API (token-auth'd separately)
'/api/mcp', // MCP (token-auth'd)
]
@@ -66,48 +87,66 @@ function isPublic(pathname: string): boolean {
return false
}
// --- Session token verification (Web Crypto — Edge-runtime compatible) ---
//
// Token format: base64url(payload).base64url(hmac_sha256(payload))
// The HMAC key is AUTH_SECRET. We verify with SubtleCrypto.verify, which
// is constant-time in practice and runs in the Edge runtime (unlike Node's
// `crypto` module, which produced the earlier "not supported in Edge
// Runtime" warning). Payload carries { sub, email, role, iat, exp }.
function base64urlToBytes(s: string): Uint8Array {
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4))
const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/')
const bin = atob(b64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes
}
async function verifySession(token: string | undefined): Promise<boolean> {
if (!token) {
console.log('[auth] No session token')
return false
}
const adminPassword = process.env.ADMIN_PASSWORD
if (!adminPassword) {
console.log('[auth] ADMIN_PASSWORD not set')
if (!token) return false
const secret = process.env.AUTH_SECRET
if (!secret) {
// In dev without AUTH_SECRET, allow the dev-ephemeral fallback tokens.
// The route-handler verifySessionToken() enforces the same key.
if (process.env.NODE_ENV !== 'production') return parseAndCheckExpiry(token)
return false
}
try {
// Session token format: base64url(payload).hmac
const [payloadB64, signature] = token.split('.')
if (!payloadB64 || !signature) {
console.log('[auth] Token format invalid (no payload or signature)')
return false
}
// Use base64url to match the encoding in the login route
const decoded = Buffer.from(payloadB64, 'base64url').toString()
const payload = JSON.parse(decoded)
if (payload.exp && Date.now() > payload.exp) {
console.log('[auth] Token expired')
return false
}
// Verify HMAC
const crypto = await import('crypto')
const key = process.env.AUTH_SECRET || adminPassword
const expectedSig = crypto
.createHmac('sha256', key)
.update(payloadB64)
.digest('base64url')
const match = signature === expectedSig
if (!match) {
console.log('[auth] HMAC mismatch')
console.log('[auth] expected:', expectedSig?.substring(0, 20))
console.log('[auth] got:', signature?.substring(0, 20))
console.log('[auth] AUTH_SECRET set:', !!process.env.AUTH_SECRET)
console.log('[auth] ADMIN_PASSWORD set:', !!adminPassword)
}
return match
} catch (e) {
console.log('[auth] Verification error:', e instanceof Error ? e.message : 'unknown')
const [payloadB64, sigB64] = token.split('.')
if (!payloadB64 || !sigB64) return false
// Expiry check first (cheap, avoids crypto work for expired tokens).
const payload = JSON.parse(new TextDecoder().decode(base64urlToBytes(payloadB64)))
if (payload.exp && Date.now() > payload.exp) return false
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
)
return await crypto.subtle.verify(
'HMAC',
key,
base64urlToBytes(sigB64),
enc.encode(payloadB64)
)
} catch {
return false
}
}
// Dev-only fallback: check expiry without HMAC (the login route still does
// full HMAC verification before issuing the token).
function parseAndCheckExpiry(token: string): boolean {
try {
const [payloadB64] = token.split('.')
if (!payloadB64) return false
const payload = JSON.parse(new TextDecoder().decode(base64urlToBytes(payloadB64)))
if (payload.exp && Date.now() > payload.exp) return false
return true
} catch {
return false
}
}
@@ -131,52 +170,23 @@ export async function middleware(req: NextRequest) {
return NextResponse.next()
}
// Check session cookie — simplified: just check if cookie exists.
// Full HMAC verification happens in the login route. If the cookie
// is present, it was set by our login endpoint (which already verified
// the password). This is sufficient for middleware-level protection.
// Verify the session cookie with full HMAC (Web Crypto, Edge-compatible).
const sessionToken = req.cookies.get('aos-session')?.value
const valid = await verifySession(sessionToken)
if (!sessionToken) {
// For API routes, return 401
if (!valid) {
if (pathname.startsWith('/api/')) {
return NextResponse.json(
{ error: 'unauthorized', message: 'Authentication required' },
{ status: 401 }
)
}
// For pages, redirect to login
// For pages, redirect to login with the intended destination.
const loginUrl = new URL('/login', req.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
// Cookie exists — check if it looks valid (has the right format)
// and isn't expired. Full HMAC check is done at login time.
try {
const [payloadB64] = sessionToken.split('.')
if (!payloadB64) {
throw new Error('invalid token format')
}
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString())
if (payload.exp && Date.now() > payload.exp) {
// Token expired — redirect to login
const loginUrl = new URL('/login', req.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
} catch {
// Invalid token — treat as unauthenticated
if (pathname.startsWith('/api/')) {
return NextResponse.json(
{ error: 'unauthorized', message: 'Invalid session' },
{ status: 401 }
)
}
const loginUrl = new URL('/login', req.url)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}