# ADR-037 — Autenticação do CMS: sessão nativa sobre `cms_users`

| Campo | Valor |
|---|---|
| **Status** | Aceito (2026-10-17) |
| **Data** | 2026-10-17 |
| **Plano de origem** | `wiki/plan/12-admin-cms-editor.md` · `wiki/implementation/04-produto-cms.md` |
| **Relacionadas** | ADR-002, ADR-005, ADR-008, ADR-009, ADR-017, ADR-020, ADR-021, ADR-032, ADR-033, ADR-035, ADR-036 |

## Contexto

A ADR-033 definiu **autorização** (permissões `{módulo}.{ação}`, perfis, `suporte` reservado) e a
ADR-035 mandou auditar `login`/`logout`/falha. O que **não** existia era **autenticação**: não havia
rota de login, o painel não era alcançável (`route:list` do domínio `cms.<base>` vazio) e a única tela
“de login” era código morto apontando para `route('login')` inexistente. Já existiam, porém, o modelo
`Core\Area\Cms\Models\User` (`cms_users`, `Authenticatable`, `password` com cast `hashed`), o provider
`eloquent` em `config/auth.php` e o `AuditAuthSubscriber` escutando `Login`/`Logout`/`Failed`.

Consequência do vazio: o shell do CMS (ADR-036) não era verificável em navegador — logo **fora do gate
de acessibilidade** (ADR-025) e do E2E (F4 exige e2e + a11y verdes para o piloto, que inclui telas de
CMS).

## Decisão

**1. Sessão nativa, sem pacote.** Guard `web` (`session`) + provider `eloquent`
(`Core\Area\Cms\Models\User`), sobre a tabela **`cms_users`**. **Nenhum** starter kit, Fortify, Breeze,
Socialite ou JWT (ADR-002): `Auth::attempt` + formulário HTML resolve o caso server-rendered (ADR-009).

**2. As rotas de autenticação pertencem à área CMS (Core), não a um módulo.**
`src/core/Area/Cms/Routes/web.php` (convenção do `AreaRegistrar`), **sem closure** (ADR-008):
`GET /login` (`login`), `POST /login` (`cms.login`), `POST /logout` (`cms.logout`), `GET /` (`cms.dashboard`).
Servir no **domínio próprio** (`cms.<base>`) dá **isolamento de cookie/sessão** por host: a sessão do
painel não é enviada ao portal público (ADR-005).

**3. Credencial e sessão blindadas.** `email` + `password`; hash **Argon2id** (ADR-020, já default em
`config/hashing.php`); `Auth::attempt` **sem** `remember` (sem “lembrar-me” no MVP — menos superfície de
roubo de sessão); `session()->regenerate()` no login (fixação de sessão) e `invalidate()` +
`regenerateToken()` no logout; cookies por perfil de `APP_ENV` (ADR-032 — `encrypt`, `http_only`,
`same_site`, `secure` já governados em `config/environments.php`).

**4. Rate limiting day-zero (ADR-021).** Limiter `cms-login` no **Valkey**, por **e-mail + IP**
(`Limit::perMinute(config('cms.auth.max_attempts'))->by(lower(email).'|'.ip)`), aplicado por
`throttle:cms-login` na rota `POST`. Resposta **429** RFC 7807 com `Retry-After` (renderer já cobre);
o limite é **configuração**, nunca escrita no código.

**5. Auditoria de graça (ADR-035).** `Login`/`Logout`/`Failed` nativos → `AuditAuthSubscriber` grava
`core.login` / `core.logout` / `core.login_failed` (ator = snapshot; **sem** credenciais no payload).
Nenhuma linha nova de auditoria; nenhuma rota pode “esquecer” de auditar.

**6. Falha não revela existência.** Credencial inválida → `ValidationException` com mensagem
**genérica** (mesma para usuário inexistente e senha errada), sem diferenciar tempo de resposta por
ramo; o detalhe vai para a trilha (`core.login_failed`), não para a tela.

**7. Painel mínimo alcançável.** `GET /` autenticado renderiza **`cms::dashboard`** em
`<x-layout::cms>` (identidade/regiões do ADR-036). Ele existe para ser a **primeira tela do CMS** — alvo
do gate de acessibilidade (axe/sniff/E2E) — e mostra apenas estado do sistema (versão, usuário), nunca
dados de módulo (o conteúdo é dos módulos, ADR-009).

**8. Login com a identidade do painel, sem navegação.** `cms::auth.login` usa o **mesmo** shell com as
regiões desligadas (`instance` do ADR-036) — sem header/sidebar/rodapé (não há navegação a mostrar a um
anônimo), com título/cor/logo da instância, `autofocus` no primeiro campo, `autocomplete` correto,
`robots: noindex` e erro anunciado (`role="alert"`).

**9. Semente de demonstração (ADR-017).** O usuário do E2E vem do **seeder de demonstração**
(`demo-data`), nunca de migração: `seeds = demo only`. `suporte@maxima.inf.br` continua sendo a
identidade reservada de suporte (ADR-033).

**Fora de escopo (explicitado):** gov.br SSO, 2FA, recuperação de senha por e-mail, “lembrar-me”,
registro/convite pelo próprio painel — backlog. Nada disso pode ser implementado sem ADR própria.

## Alternativas descartadas

- **Fortify/Breeze/Jetstream** — descartada: pacote pesado e telas que não controlamos (ADR-002/ADR-009);
  o caso é um formulário + `Auth::attempt`.
- **JWT/API stateless para o CMS** — descartada: CMS é server-rendered; token no cliente adiciona
  superfície e complexidade sem ganho.
- **Rotas de login no domínio do portal (path `/cms/login`)** — descartada: quebra o isolamento de
  cookie/sessão por host e desalinha com a área por subdomínio (ADR-005).
- **Só o rate limit do proxy (Apache)** — descartada: ADR-021 exige camada de aplicação no caminho de
  autenticação (força bruta distribuída).
- **Login em modal/JS** — descartada: caminho crítico precisa funcionar **sem JS** (ADR-010).

## Consequências

**Positivas**
- Painel alcançável e **coberto pelo gate de a11y/E2E** — o shell do ADR-036 passa a ser verificável.
- Auditoria e rate limiting herdados de decisões já tomadas: pouca linha nova, nenhuma política nova.
- Sessão do painel isolada por host e sem “lembrar-me” (superfície mínima).

**Custos / negativas**
- Sem recuperação de senha/2FA/SSO no MVP (backlog) — em produção, o suporte reseta credencial
  (ação auditada).
- Sessão por host exige novo login ao trocar de domínio de área (comportamento desejado, mas é atrito).

## Verificação

- `CmsAuthTest`: tela de login (200, `noindex`, campos rotulados), painel exige autenticação
  (visitante → `login`), login correto → painel + `session()->regenerate`, credencial inválida →
  mensagem genérica + `core.login_failed` na trilha, **429** + `Retry-After` no limite, logout
  invalida a sessão, autenticado no `/login` → painel.
- E2E (Playwright, projeto `cms-desktop`): jornada de login e axe WCAG 2.2 AA no painel, com o shell
  do ADR-036 (regiões/posições) em tela real.
