3bb178b5-949c-4434-a4e8-093caab5d33b
This commit is contained in:
1 parent
cd7591fd65
commit
72ec15c2ab
21 files changed
+1991
-130
No files matched your search
+83
-73
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user