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 cookieHttpOnlyno login, objetouserno body, todas as rotas/api/v1protegidas, logout apaga o cookie. Duas roles (admin/attendant), sem denylist, sem refresh e semme.
Nota de status: a spec original ficou marcada como
proposed. Está implementada — commit1e7a3ed feat: login with e-mail/password JWT (#13). O status foi corrigido paradonena 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/loginautentica um usuário ativo por e-mail/senha, responde200 { 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/v1exigem cookie de JWT válido; sem token/expirado →401 unauthorized. DELETE /api/v1/auth/logoutapaga o cookie do JWT →204.- Validação do token sem consulta ao banco por request (só assinatura + expiração).
- JWT carrega
sub(user id) erolenos 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— adicionaencrypted_password:string, null: false, default: ""emusers(requisito dodatabase_authenticatable).db/schema.rb— regenerado.- Sem tabela de denylist — desenho stateless.
Modelos
app/core/models/user.rb— adicionadevise :database_authenticatable, :jwt_authenticatable, jwt_revocation_strategy: JwtNoRevocation. Mantém validações/normalização e as duas roles. Sobrescreve:active_for_authentication?→ sóstatus activeautentica;inactive_message→:user_not_active(mapeado para403);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?semprefalse,revoke_jwtno-op, nenhum acesso ao banco.app/core/use_cases/authenticate/login.rb— o use case que resolve e-mail+senha, devolvendoSuccess(user:)ouFailure(: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—createchama o use caseAuthenticate::Login; no sucesso emite o token viaWarden::JWTAuth::UserEncoder, grava o cookie (httponly: true,secureesame_sitepor ambiente,expires= TTL) e responde200com ouserpeloUserSerializer— sem token no body e sem segundo cookie.destroyapaga o cookie e responde204. As duas ações fazemskip_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) erender_errorpara o contrato de erro.app/ports/serializers/user_serializer.rb—{ id, name, email, role, status }; nunca expõe o hash.HealthControllereauth/sessions#createpermanecem públicos.
Divergências do planejado: o guard chama-se
authenticate_request!(nãoauthenticate_user!); a autenticação foi extraída para um use case emapp/core/use_cases/, em vez de ficar no controller; e oJwtCookieMiddlewareemapp/platform/não foi necessário —app/platform/segue vazio.
Seed e testes
db/seeds.rb— definepasswordpara 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ó paraactive;jwt_payloadincluirole.test/ports/controllers/api/v1/auth/sessions_controller_test.rb— login ok (200 { user }sem hashSet-CookieHttpOnly, sem token no body); senha errada (401); e-mail inexistente (401); usuárioinactive/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 seedroda 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 }, comSet-Cookiedo JWTHttpOnly; nenhum token no body. - Senha errada →
401 { error: { code: "invalid_credentials" } }; usuárioinvited(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→204e 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.