gabrielb 71e1a3f970 feat: portal Nuxt 3 com BFF + autenticação Keycloak (Fase 1)
Substitui o portal Vite+Vue puro por Nuxt 3 com BFF embutido (Nitro server
routes) e fluxo de autenticação Keycloak via token-handler pattern.

Server (BFF):
- server/api/auth/{login,callback,refresh,logout,me}.ts — Keycloak PKCE
- server/api/proxy/[...path].ts — proxy autenticado pro core-api com tenant
- server/utils/{session,keycloak,pkce,redis,tenant,prefeitura}.ts
- server/middleware/csrf.ts — Origin check + header X-Requested-With

Auth (token-handler pattern):
- JWT vive só server-side em Redis; cliente recebe cookie session-id opaco
- Refresh transparente quando access_token expira
- Multi-tenant via hostname → X-Municipio/X-Dominio injetados no proxy
- Realm dedicado: modumfiscal-portal-{env}

Frontend (Nuxt):
- src/pages/** (file-based routing) substitui src/views/
- Plugins SSR: prefeitura (bootstrap pré-hidratação) + auth (hidrata user via /api/auth/me)
- Composables useAuth, useApi, useLoginModal, useFocusLoginInput
- Modal global de login quando middleware /portal/** bloqueia
- Splash overlay no boot esconde flash do preset inicial pro tema dinâmico
- DocumentoInput bloqueia campo quando user autenticado (pré-preenche em certidão/IPTU)

Removidos:
- index.html, vite.config.js, src/main.js, src/router/
- src/config/apiClient.js (substituído por \$fetch via /api/proxy)
- src/services/{auth,prefeitura}Service.js (lógica migrada pra composables/plugins)
- src/mocks/ (não mais usado)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:31:19 -03:00

46 lines
1.7 KiB
TypeScript

/**
* CSRF defense para o BFF.
*
* Aplicada apenas em rotas `/api/**` com métodos mutating (POST/PUT/PATCH/DELETE).
*
* Dupla camada:
* 1. Origin/Referer check — sender precisa bater com o host da requisição
* 2. Header custom `X-Requested-With: fetch` — força preflight CORS em cross-origin,
* o que o browser bloqueia antes mesmo de chegar aqui (sem CORS permissivo configurado)
*
* Bypass: `/api/auth/callback` (GET externo do Keycloak — não é mutating mesmo)
*/
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
export default defineEventHandler((event) => {
const url = getRequestURL(event)
if (!url.pathname.startsWith('/api/')) return
const method = event.method.toUpperCase()
if (SAFE_METHODS.has(method)) return
// 1. Origin/Referer precisa casar com host
const host = getRequestHost(event, { xForwardedHost: true })
const origin = getRequestHeader(event, 'origin')
const referer = getRequestHeader(event, 'referer')
const sender = origin ?? referer
if (!sender) {
throw createError({ statusCode: 403, statusMessage: 'CSRF: Origin/Referer obrigatório' })
}
try {
const senderHost = new URL(sender).host
if (senderHost !== host) {
throw createError({ statusCode: 403, statusMessage: 'CSRF: Origin não confiável' })
}
} catch {
throw createError({ statusCode: 403, statusMessage: 'CSRF: Origin inválido' })
}
// 2. Header custom — sem CORS permissivo, browser bloqueia cross-origin
const requestedWith = getRequestHeader(event, 'x-requested-with')
if (requestedWith !== 'fetch') {
throw createError({ statusCode: 403, statusMessage: 'CSRF: header X-Requested-With ausente' })
}
})