Auth: login e-mail/senha com JWT stateless em cookie HttpOnly

TLDR: autenticação por e-mail/senha no cobranca-api (devise + devise-jwt): JWT stateless em cookie HttpOnly no login, objeto user no body, todas as rotas /api/v1 protegidas, logout apaga o cookie. Duas roles (admin/attendant), sem denylist, sem refresh e sem me.

Nota de status: a spec original ficou marcada como proposed. Está implementada — commit 1e7a3ed feat: login with e-mail/password JWT (#13). O status foi corrigido para done na migração da documentação, e as divergências entre o texto planejado e o código estão anotadas em Mudanças.

Contexto

O User (app/core/models/user.rb) tem name, email, role (admin/attendant), status (active/inactive/invited) — sem credenciais. O seed (db/seeds.rb) diz literalmente “no password yet”. Nenhuma rota /api/v1 é protegida.

Esta spec entrega, de forma autocontida, a autenticação do cobranca-api. São duas roles apenas — admin e attendant — exatamente o que o model já define; nada de RBAC de múltiplos perfis, refresh ou endpoint me neste momento. A role vai nos claims do JWT, deixando o gancho pronto caso a autorização por perfil evolua depois.

A stack de auth já está no Gemfile: devise + devise-jwt. Esta spec concretiza esse uso dentro do layout da Arrow Architecture (app/core models + use cases, app/ports controllers).

Depois de implementado o back-end, uma branch derivada (feat/login) faz a integração com o front (services/session.ts, LoginPage) — ver USER-003.

Decisões de desenho

Decisão Escolha Por quê
Revogação Nenhuma (strategy no-op) Uma denylist obrigaria um SELECT do jti a cada request protegida só para suportar logout — gargalo desnecessário. O devise-jwt exige uma strategy, mas ela não toca o banco
Expiração 7 dias (JWT_ACCESS_TTL) Compensa a ausência de refresh
Transporte do token Cookie HttpOnly, nunca no body Retornar o token no corpo o expõe a XSS. O browser envia o cookie sozinho a cada request
Objeto user No body do login → localStorage no front Restaura a sessão/UI no reload sem endpoint me. É display-only: a autorização é sempre server-side a partir do JWT
Logout Apagar o cookie + limpar o localStorage Sem estado no servidor

Supersede registrado: a primeira versão desta spec gravava o user num segundo cookie legível (não-HttpOnly). Trocado por localStorage porque (a) o cookie ia à API em toda request sem ser usado, e (b) a leitura via document.cookie era mais frágil. Segurança é equivalente: o token continua só no cookie HttpOnly; o user é display-only nos dois desenhos. O trade-off aceito é que o localStorage não expira junto com o token — o front precisa limpá-lo explicitamente no logout e sempre que uma request protegida responder 401.

Trade-off aceito: sem revogação server-side, um token vazado permanece válido até expirar (7 dias); o logout invalida apenas o cookie no navegador, não o token em si. Aceitável para o escopo atual.

Objetivos

  • POST /api/v1/auth/login autentica um usuário ativo por e-mail/senha, responde 200 { user } e grava o cookie do JWT (HttpOnly/Secure/SameSite).
  • Credenciais inválidas → 401 invalid_credentials; usuário não-ativo (inactive/invited) → 403 user_not_active — no contrato de erro { error: { code, message } }.
  • Todas as rotas de domínio sob /api/v1 exigem cookie de JWT válido; sem token/expirado → 401 unauthorized.
  • DELETE /api/v1/auth/logout apaga o cookie do JWT → 204.
  • Validação do token sem consulta ao banco por request (só assinatura + expiração).
  • JWT carrega sub (user id) e role nos claims.
  • Seed com senha, para testar login ponta a ponta (make seed + curl) sem depender do console.

Fora de escopo

  • Cadastro/convite de usuários.
  • Refresh token e endpoint me.
  • RBAC de múltiplos perfis e alçadas de aprovação.
  • A integração com o front (USER-003).

Mudanças

Modelo de dados

  • db/migrate/*_add_encrypted_password_to_users.rb — adiciona encrypted_password:string, null: false, default: "" em users (requisito do database_authenticatable).
  • db/schema.rb — regenerado.
  • Sem tabela de denylist — desenho stateless.

Modelos

  • app/core/models/user.rb — adiciona devise :database_authenticatable, :jwt_authenticatable, jwt_revocation_strategy: JwtNoRevocation. Mantém validações/normalização e as duas roles. Sobrescreve:
    • active_for_authentication? → só status active autentica;
    • inactive_message → :user_not_active (mapeado para 403);
    • jwt_payload → inclui { "role" => role } nos claims.

    Não usa :validatable — mantém as validações próprias de e-mail já existentes.

  • app/core/models/jwt_no_revocation.rb — revocation strategy no-op: jwt_revoked? sempre false, revoke_jwt no-op, nenhum acesso ao banco.
  • app/core/use_cases/authenticate/login.rb — o use case que resolve e-mail+senha, devolvendo Success(user:) ou Failure(:invalid_credentials | :user_not_active | :invalid_attributes).

Configuração

Arquivo Mudança
config/initializers/devise.rb API-only: navigational_formats = [], parent_controller = "ApplicationController", config.jwt com secret = ENV["JWT_SECRET"], expiration_time de 7 dias, dispatch_requests no login. Warden usando o failure app JSON
config/initializers/cors.rb credentials: true (necessário para cookies cross-origin), origins restritas a CORS_ALLOWED_ORIGINS — não pode ser wildcard com credentials
config/initializers/rack_attack.rb Throttle anti brute-force no POST /api/v1/auth/login por e-mail+IP (ex.: 5/min), 429 no mesmo contrato
config/routes.rb Dentro de namespace :api { namespace :v1 }: post "auth/login" => "auth/sessions#create" e delete "auth/logout" => "auth/sessions#destroy"
config/locales/en.yml Mensagens user_not_active / invalid_credentials
Gemfile Garantir bcrypt (dependência do database_authenticatable)
.env.example (raiz) JWT_SECRET, JWT_ACCESS_TTL (default 604800)

Controllers (ports)

  • app/ports/controllers/api/v1/auth/sessions_controller.rb — create chama o use case Authenticate::Login; no sucesso emite o token via Warden::JWTAuth::UserEncoder, grava o cookie (httponly: true, secure e same_site por ambiente, expires = TTL) e responde 200 com o user pelo UserSerializer — sem token no body e sem segundo cookie. destroy apaga o cookie e responde 204. As duas ações fazem skip_before_action :authenticate_request!.
  • app/ports/controllers/api/v1/base_controller.rb — before_action :authenticate_request! (rotas de domínio herdam e ficam protegidas por padrão) e render_error para o contrato de erro.
  • app/ports/serializers/user_serializer.rb — { id, name, email, role, status }; nunca expõe o hash.
  • HealthController e auth/sessions#create permanecem públicos.

Divergências do planejado: o guard chama-se authenticate_request! (não authenticate_user!); a autenticação foi extraída para um use case em app/core/use_cases/, em vez de ficar no controller; e o JwtCookieMiddleware em app/platform/ não foi necessário — app/platform/ segue vazio.

Seed e testes

  • db/seeds.rb — define password para os usuários semeados e atualiza o resumo (remove “no password yet”), mantendo idempotência.
  • test/fixtures/users.yml — encrypted_password (hash bcrypt) para as fixtures logarem.
  • test/models/user_test.rb — autenticação com senha correta/errada; active_for_authentication? só para active; jwt_payload inclui role.
  • test/ports/controllers/api/v1/auth/sessions_controller_test.rb — login ok (200 { user } sem hash
    • Set-Cookie HttpOnly, sem token no body); senha errada (401); e-mail inexistente (401); usuário inactive/invited (403 user_not_active); logout (204 + cookie limpo).
  • test/ports/controllers/api/v1/base_controller_test.rb — rota protegida sem cookie → 401; com cookie válido → passa; token expirado → 401.

Sequência de implementação

Ciclo TDD por feature (test → feat → refactor). Cada passo deixa a suíte verde.

# Tipo Entrega
1 feat Migration, JwtNoRevocation, devise no User, initializer devise.rb, bcrypt
2 test Model — senha certa/errada, active_for_authentication?, jwt_payload com role
3 feat Ajustes no User/JwtNoRevocation até verde
4 test Request — login 200 { user } + cookie, 401/403, logout 204
5 feat Use case Authenticate::Login, SessionsController, rotas, serializer, throttle, CORS, locales
6 test Proteção de rota — sem cookie 401, com cookie válido ok, expirado 401
7 feat authenticate_request! + contrato de erro no BaseController, failure app JSON
8 feat Seed com senha; .env.example com JWT_SECRET/JWT_ACCESS_TTL
9 refactor Nomes, extração do serializer, remoção de duplicação

Como verificar

  • make backend.test (Minitest) passa.
  • make seed roda sem erro e cria usuários com senha.
  • Login (guardando cookies): bash curl -sc cookies.txt -X POST localhost:4010/api/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"francisco@ibft.com.br","password":"cobranca123"}' → 200 { user }, com Set-Cookie do JWT HttpOnly; nenhum token no body.
  • Senha errada → 401 { error: { code: "invalid_credentials" } }; usuário invited (marcos@) → 403 user_not_active.
  • Rota protegida sem cookie → 401 { error: { code: "unauthorized" } }; com o cookie salvo (curl -b cookies.txt ...) → passa.
  • curl -b cookies.txt -c cookies.txt -X DELETE localhost:4010/api/v1/auth/logout → 204 e cookie removido.

Documentação

  • USER-002 — Autenticação — reescrita para refletir o que esta spec entrega: login por e-mail/senha, duas roles, JWT stateless em cookie HttpOnly, proteção de rota e logout — sem refresh, me, RBAC de 4 perfis ou alçadas.
  • Camadas do app Rails — onde os use cases e ports se encaixam.