Este documento explica como o Beyou se defende: como usuários provam quem são, como cada requisição é validada e limitada, como ações destrutivas exigem um segundo fator e quais guardas se recusam a sequer subir o servidor quando a produção está mal configurada. Termina com uma avaliação honesta do que ainda falta.
Uma nota de enquadramento antes: o backend nunca termina TLS. O HTTPS, e portanto a segurança de cada cookie e header abaixo, é trabalho do proxy reverso na frente dos containers presos em loopback. O tópico de infraestrutura cobre essa camada.
flowchart LR
subgraph client["Cliente"]
FE["⚛️ Web / 📱 Mobile<br/>JWT em memória"]
end
subgraph filters["Pipeline de requisição"]
RL["🚦 RateLimitFilter<br/>faixas bucket4j"]
SF["🛡️ SecurityFilter<br/>validação do JWT"]
DS["🔏 DocsImportSecretFilter"]
end
subgraph server["Lado do servidor"]
LA["🔒 Lockout de login<br/>por conta"]
TS["🔑 TokenService<br/>HMAC256, 15 min"]
RT["🔄 Refresh tokens<br/>hash BCrypt, rotacionados"]
OWN["👤 Checagens de posse<br/>em cada service"]
end
FE -->|"Authorization: Bearer"| SF
FE -->|"cookie / corpo de refresh"| RT
SF --> RL --> OWN
SF --> TS
FE <-->|"OAuth 2.0"| GO["🔐 Google"]
LA -.-|"protege"| TS
DS -.-|"protege /docs/admin"| OWN
O design em cinco linhas:
X-Access-Token.| Endpoint | Método | Auth | Propósito |
|---|---|---|---|
| /auth/login | POST | Não | Login com e-mail + senha |
| /auth/register | POST | Não | Registro (verificação de e-mail exigida antes do login) |
| /auth/verify-email | GET | Não | Consome o token de verificação de 24 horas |
| /auth/resend-verification | POST | Não | Emite um novo token de verificação e o envia por e-mail; sempre o mesmo 200 |
| /auth/google | GET | Não | Troca de código do Google OAuth (web) |
| /auth/google/mobile | POST | Não | Verificação de ID token do Google (mobile) |
| /auth/refresh | POST | Não | Rotaciona o refresh token, emite novo JWT |
| /auth/logout | POST | Não | Limpa o cookie, revoga o token |
| /auth/verify | GET | Sim | Sonda de sessão; devolve "authenticated" |
| /auth/forgot-password | POST | Não | Pede o e-mail de redefinição |
| /auth/reset-password/validate | GET | Não | Pré-valida um token de reset |
| /auth/reset-password | POST | Não | Define a nova senha |
Todos os endpoints de auth sem autenticação dividem um mesmo balde de rate limit: 5 requisições por 15 minutos por IP.
sequenceDiagram
participant U as Usuário
participant BE as Backend
participant DB as Banco
U->>BE: POST /auth/login
BE->>BE: Checa lockout (10 falhas / 15 min por e-mail)
BE->>DB: Busca usuário pelo e-mail
BE->>BE: BCrypt.matches(entrada, hash)
BE->>BE: emailVerified? senão 403 EMAIL_NOT_VERIFIED
BE->>DB: Cria refresh token (hash guardado)
BE-->>U: JWT em X-Access-Token + cookie de refresh
A ordem das checagens é a parte interessante:
matches sempre falha.O registro guarda o usuário com um token de verificação de 32 bytes (expira em 24 horas) e envia o e-mail de confirmação. O token é de uso único: consumi-lo marca emailVerified e anula as duas colunas. A política de senha é imposta na camada de service, com os DTOs de reforço: pelo menos 12 caracteres e pelo menos 2 das 4 classes de caracteres.
Um tradeoff honesto, declarado no código: o registro responde "Email already in use" para um endereço ocupado, então é um oráculo de enumeração por escolha. O balde de 5 por 15 minutos por IP é o que impede isso de ser explorado em escala.
Dois caminhos separados, um por plataforma:
GET /auth/google?code=): o backend troca o código de autorização com o Google no servidor (o client secret nunca sai de lá) e lê o perfil com o access token resultante. O cliente web gera e confere seu próprio valor de state antes de entregar o código.POST /auth/google/mobile): o app nativo envia um ID token do Google, que o backend verifica com o verificador oficial: assinatura contra as chaves publicadas do Google, emissor, expiração e uma lista de audiences permitidas. O token ainda é rejeitado a menos que o próprio Google reporte o e-mail como verificado.Os dois caminhos fazem find-or-create pelo e-mail. Contas criadas via Google ganham isGoogleAccount=true, um marcador de senha que não é hash e pulam a verificação de e-mail (o Google já a fez).
Os dois caminhos também recusam uma conta encontrada que seja de senha e com endereço não verificado, devolvendo o mesmo 403 EMAIL_NOT_VERIFIED do login. Até essa guarda existir, o doLogin era o único lugar do backend que lia emailVerified, então o Google era um desvio do portão — de leve, como cura acidental de um e-mail de verificação perdido, e a sério assim: qualquer um cadastra um endereço que não é seu, e a linha não verificada que sobra engoliria o login Google do dono de verdade, sem clique e sem aviso. O dono enche aquela linha com seus dados, e se um dia seguir o link de verificação que chegou quando o invasor se cadastrou, a flag vira e a senha do invasor abre a conta. Agora uma regra só vale em todas as portas, e ela é recuperável em vez de apenas rígida porque o endpoint de reenvio chegou junto. Uma conta de senha já verificada continua podendo vincular o Google à vontade.
| Propriedade | Valor |
|---|---|
| Algoritmo | HMAC256 (auth0 java-jwt) |
| TTL | 15 minutos |
| Claims | iss=auth-api, sub=e-mail, exp. Nada além |
| Entrega | Header de resposta X-Access-Token |
| Consumo | Header de requisição Authorization: Bearer |
| Armazenamento | Só na memória do frontend |
As claims são mínimas de propósito. O papel não está no token; o SecurityFilter relê a linha do usuário a cada requisição, então uma mudança de papel ou uma conta apagada vale em uma requisição, não em um tempo de vida de token. O custo é uma leitura de banco por requisição autenticada, uma troca real.
HMAC256 em vez de RSA porque só este backend assina e verifica: não há terceiro para receber uma chave pública.
O token do cliente é {rowId}.{segredo}: um UUID nomeando a linha do banco mais 32 bytes aleatórios. O banco guarda só o hash BCrypt do segredo, então uma tabela vazada não contém nada reutilizável.
flowchart TD
CR["🔑 32 bytes aleatórios"] --> HASH["🔒 Hash BCrypt (custo 12)"]
HASH --> DB["💾 Linha: id + hash + expiresAt + revokedAt"]
CR --> OUT["📤 Para o cliente: id.segredo"]
OUT --> REF["🔄 POST /auth/refresh"]
REF --> MATCH["matches(segredo, hash)?<br/>expirado? revogado?"]
MATCH --> ROT["Revoga a linha antiga, emite novo par"]
Secure e SameSite=Strict em produção (Lax em dev), path /, maxAge de 15 dias.X-Client: mobile, o backend pula o cookie por completo e devolve o refresh token no corpo da resposta; os refreshes seguintes o mandam de volta no header X-Refresh-Token. Cookies casam mal com stacks HTTP nativas, então o mobile é dono do próprio armazenamento.Três filtros próprios cooperam, e a ordem importa:
| Ordem | Filtro | Trabalho |
|---|---|---|
| 1 | SecurityFilter (antes do UsernamePasswordAuthenticationFilter) | Lista de bypass para caminhos públicos; caso contrário extrai o Bearer token, valida assinatura/expiração/emissor, carrega o usuário e popula o SecurityContext. Falhas respondem 401 com ApiErrorResponse chaveado (JWT_NOT_FOUND, AUTH_HEADER_INVALID, JWT_INVALID, USER_NOT_FOUND) |
| 2 | DocsImportSecretFilter (depois do UsernamePasswordAuthenticationFilter) | Comparação em tempo constante do header X-Docs-Import-Secret para /docs/admin/import/*; segredo configurado em branco falha fechado com 403 |
| 3 | RateLimitFilter (filtro servlet comum) | Roda depois da cadeia de segurança, exatamente o que permite chavear baldes por usuário autenticado |
Dois detalhes valem conhecer antes de mexer nesse código. Primeiro, a lista de caminhos públicos existe duas vezes: como matchers permitAll no SecurityConfig e como as condições de bypass do SecurityFilter. Elas concordam hoje, mas casam de formas diferentes (equals versus startsWith), e a deriva entre as duas é silenciosa. Segundo, dispatches assíncronos passam pela cadeia porque o stream SSE do agente redespacha; a invariante compensatória é que todo endpoint protegido precisa autenticar e checar posse no dispatch inicial.
Baldes bucket4j em um cache Caffeine, a primeira faixa que casa vence:
| Faixa | Endpoints | Limite | Chaveado por |
|---|---|---|---|
| auth | login, register, forgot-password, resend-verification, google, google/mobile | 5 / 15 min | IP |
| agent | POST /ai/agent/chats/* | 30 / hora | usuário |
| docs | /docs/* (público) | 30 / min | IP |
| photo | GET /user/photo/* | 120 / min | IP |
| onboarding | POST /onboarding/suggestions | 30 / hora | usuário |
| account-deletion | POST /user/deletion/* | 10 / hora | usuário |
| feedback | POST /feedback | 10 / hora | usuário |
| feedback-attachment | POST /feedback/*/attachments | 20 / hora | usuário |
| export | GET /user/export | 5 / hora | usuário |
| write | qualquer outro POST/PUT/DELETE | 30 / min | usuário |
| read | qualquer outro GET | 60 / min | usuário |
O export fica acima da faixa de leitura genérica por um motivo que vale registrar: é um GET, mas devolve a conta inteira em uma resposta — cada categoria, hábito, tarefa, meta, rotina, conversa de feedback e conversa com o assistente, montadas em memória e serializadas de uma vez. Sessenta por minuto disso é um jeito de segurar a heap, e ninguém que está levando os próprios dados precisa de uma sexta cópia dentro da hora.
Rejeições respondem 429 com header Retry-After; sucessos carregam X-Rate-Limit-Remaining. Os dois estão citados no Access-Control-Expose-Headers, sem o que nenhum navegador consegue ler nenhum deles: nenhum está na safelist do CORS, então a espera ia no fio e era inalcançável para o cliente web.
O IP do cliente vem do header CF-Connecting-IP, não do X-Forwarded-For, e a razão vale lembrar: o Cloudflare acrescenta ao X-Forwarded-For em vez de substituí-lo, então a entrada mais à esquerda é controlada pelo atacante, e honrá-la entregaria um balde de login novo por requisição. Quando o header falta, o filtro cai para o endereço do socket, que atrás de um túnel colapsa em um balde compartilhado. Esse caso degradado é exatamente o motivo de o lockout de login por conta existir como segunda camada independente.
O subsistema inteiro fica desligado nos perfis e2e e test, e as faixas chaveadas por usuário deixam passar requisições sem autenticação.
{rowId}.{segredo}, com hash BCrypt em repouso, uso único, TTL de 15 minutos, e pedir um novo invalida todos os anteriores.Excluir a conta é a única ação onde uma sessão logada deliberadamente não basta: o fluxo exige prova de acesso à caixa de entrada.
POST /user/deletion/code envia por e-mail um código de seis dígitos. Hash BCrypt em repouso, TTL de 15 minutos, cooldown de 60 segundos entre pedidos, e cada código novo invalida os anteriores.POST /user/deletion/confirm checa, nesta ordem: já usado, expirado, tentativas demais (5) e então a comparação do hash. O contador de tentativas incrementa na própria transação REQUIRES_NEW, porque a exceção que segue um palpite errado desfaz a transação externa, e contar inline deixaria o teto inalcançável.Não existe segurança em nível de método no código, de propósito. O modelo é uma regra aplicada em todo lugar: cada método de service recebe o id do usuário autenticado e o compara com o dono da entidade carregada, lançando um erro chaveado no desencontro (CATEGORY_NOT_OWNED, HABIT_NOT_OWNED, TASK_NOT_OWNED, GOAL_NOT_OWNED, ROUTINE_NOT_OWNED, SNAPSHOT_NOT_OWNED, CHAT_NOT_OWNED, FEEDBACK_NOT_OWNED). Schedules passam pela rotina dona, o que fechou um IDOR antigo. Tudo isso aparece como HTTP 400 com errorKey; os clientes discriminam pela chave, não pelo status.
Existe exatamente uma regra de papel: /feedback/admin/** exige ADMIN. O papel ADMIN é concedido apenas por update manual no banco. Nenhum seed, endpoint ou variável de ambiente cria um admin.
Os dois caminhos de upload (foto de perfil, anexos de feedback) dividem a mesma forma defensiva:
Uma foto fica guardada em dois lugares sem relação entre si e é lida em ordem de prioridade, e é exatamente por isso que remover precisou de um endpoint próprio. O upload escreve {upload-dir}/user-photos/{userId}.jpg e nunca toca na linha do usuário; perfilPhoto, na linha, guarda uma URL do CDN do Google, gravada só no login OAuth. O UserMapper procura o arquivo primeiro e cai na coluna depois.
O DELETE /user/photo limpa os dois. Limpar uma metade só sempre deixa uma foto na tela: apague apenas o arquivo e uma conta Google volta para o avatar que tinha antes; limpe apenas a coluna e o arquivo enviado continua sendo servido. O segundo caso também explica por que PUT /user com photo vazio nunca funcionou como remoção, e foi nisso que os usuários bateram.
O arquivo é apagado antes de a coluna ser limpa, e uma falha ao apagar desfaz tudo. A ordem inversa pode gravar "esta conta não tem foto" sobre um JPEG que ainda está em disco e ainda ganha a prioridade, que é o único resultado pior do que recusar.
O id da conta vem do token, nunca do caminho, então o endpoint não tem nada da superfície de enumeração que o GET precisou de assinatura para fechar.
Ler a foto de volta é o único lugar daqui onde a autorização não viaja num header. Quem chama é uma <img src> na web e uma <Image uri> no celular, e nenhuma das duas manda header, então GET /user/photo/{userId} respondia a qualquer um que soubesse citar um id de usuário. Toda foto enviada era legível percorrendo o espaço de UUIDs.
A URL carrega a própria prova:
/api/v1/user/photo/{userId}?v={mtime}&exp={epoch}&sig={HMAC-SHA256(userId|exp)}
HMAC(TOKEN_SECRET, "beyou-photo-url-v1"), então não existe um segundo segredo para implantar, e uma assinatura de foto não serve como token em nenhum outro lugar.UserMapper cunha a URL ao responder GET /user, e nada mais cunha nenhuma. O login não: ele mapeia o usuário sem a versão da foto, então quem quer a URL assinada precisa pedir o perfil.exp está coberto pela assinatura, então o prazo não pode ser estendido editando a query string. O TTL padrão é de 12 horas (PHOTO_URL_TTL_MINUTES), o que mantém o avatar desenhado numa aba esquecida a noite inteira enquanto uma URL capturada num log de proxy para de funcionar no mesmo dia.MessageDigest.isEqual, então um palpite parcial não revela quanto dele estava certo.Cache-Control é private, porque um cache compartilhado continuaria servindo os bytes depois da assinatura expirar.O custo é que a URL funciona para quem estiver com ela até o exp passar, incluindo qualquer um que receber ela encaminhada. Ela expõe uma única imagem que quem mandou já podia ver.
O chat do agente chama ferramentas reais, então seu modelo de autoridade importa:
Headers definidos pelo backend em toda resposta:
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https://accounts.google.com https://www.googleapis.com; font-src 'self' https: data:; frame-ancestors 'none'CORS: um único padrão de origem vindo do ambiente, credenciais habilitadas e exatamente um header exposto: X-Access-Token. Dev roda curinga; produção o recusa (próximo parágrafo).
Validadores de boot, a camada do "recusa a subir":
| Guarda | Recusa o boot quando |
|---|---|
| SecurityConfigValidator (só prod) | Padrão de CORS é *, segredo do JWT com menos de 32 caracteres, cookie.secure falso, ou qualquer atalho de e2e (exposição do código de exclusão, e-mail auto-verificado) habilitado |
| SchemaOwnershipGuard | Flyway ligado mas o ddl-auto do Hibernate diferente de validate ou none |
| E2eSafetyCheck (perfil e2e) | A URL do datasource não parece um banco de teste |
Postura operacional: o actuator vive em porta própria presa em loopback, com lista fixa de endpoints em produção (o override por ambiente é descartado lá de propósito); o Swagger fica desligado em produção; o log de AOP registra contagem de argumentos, nunca valores; o container roda como usuário não-root; o CI roda CodeQL e uma checagem semanal de dependências OWASP.
| Área | Estado atual | Nota honesta |
|---|---|---|
| 2FA / MFA | Não implementado | O código por e-mail da exclusão é o único segundo fator do produto |
| Log de auditoria | Não implementado | Logins falhos, resets e refreshes não deixam trilha dedicada |
| Vínculo do refresh token | Sem vínculo a dispositivo ou IP | A rotação limita a janela de dano, mas um token roubado funciona em qualquer lugar até lá |
| Vinculação de conta Google | Find-or-create por e-mail, só contas verificadas | Uma conta de senha VERIFICADA ainda é logada por uma identidade Google coincidente sem etapa explícita de vinculação. O caso não verificado, que era o perigoso, agora é recusado |
| Enumeração no registro | "Email already in use" por escolha | Com rate limit, e um tradeoff de usabilidade, mas ainda um oráculo |
| Nuance do cooldown de reset | 400 dentro do cooldown para contas reais | Um sondador paciente distingue endereços conhecidos num segundo pedido |
| Throttle do verify-email | Sem limite | GET sem autenticação que escapa das faixas por usuário; a entropia do token é a única guarda. O POST /auth/resend-verification irmão ESTÁ na faixa auth |
| Token de verificação em repouso | Coluna em texto plano na linha do usuário | O token de reset é guardado como hash BCrypt; este sai legível de um dump do banco |
| Segredo do docs import | Comparado em tempo constante, falha fechado em branco | Nada valida seu comprimento ou entropia no boot |
| Prompt injection | Defesa só por instrução | Sem filtragem programática do texto do usuário antes do modelo |
| Teste de regressão do CSP | O teste garante a existência do header, não o valor | Um enfraquecimento silencioso do CSP passaria na suíte |
| Ameaça | Mitigada? | Como |
|---|---|---|
| Roubo de senha em vazamento do banco | Sim | BCrypt custo 12; segredos de refresh/reset/exclusão também guardados como hash |
| XSS roubando tokens | Em grande parte | JWT em memória, refresh em cookie HttpOnly, CSP nas respostas da API |
| CSRF | Sim | Auth bearer stateless; cookie lido só por refresh/logout; SameSite Strict em prod |
| Força bruta no login | Sim | 5/15min por IP mais lockout de conta em 10 falhas |
| Enumeração de usuários | Em grande parte | Login e reset são silenciosos; o registro e o cooldown do reset são as exceções documentadas |
| Replay de token após rotação | Sim | Refresh tokens antigos são revogados transacionalmente |
| IDOR | Sim | Checagem de posse em cada service, erros chaveados, schedule roteado pela rotina |
| Bombas de descompressão | Sim | Teto de pixels no cabeçalho antes do decode |
| Ferramentas de IA como confused deputy | Sim | ToolContext montado no servidor; ferramentas herdam só a autoridade do chamador |
| Fixação de sessão | Sim | Sessões não existem |