Segurança

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.

Segurança em uma olhada

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:

Endpoints de autenticação

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.

Como o login funciona

E-mail + senha

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:

  1. O lockout por conta roda antes de tudo. Dez falhas na janela travam o e-mail por 15 minutos, e o contador registra e-mails desconhecidos também, então o próprio lockout não serve para descobrir quais contas existem.
  2. Conta travada e senha errada devolvem o mesmo corpo 401. Sem oráculo.
  3. Contas Google nunca passam nessa checagem: a senha guardada é um marcador literal, não um hash BCrypt, então o matches sempre falha.
  4. Uma conta não verificada recebe um 403 EMAIL_NOT_VERIFIED distinto. Esse caminho troca deliberadamente um pouco de resistência a enumeração por uma mensagem usável de "confira sua caixa de entrada", e a tela de login agora pendura um botão de reenvio nela, então a troca compra uma saída em vez de só uma explicação.
  5. O sucesso zera o contador de falhas.

Registro e verificação de e-mail

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.

Google OAuth

Dois caminhos separados, um por plataforma:

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.

Tokens

JWT de acesso

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.

Refresh token

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"]

O pipeline de requisição

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.

Rate limiting

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.

Redefinição de senha

Exclusão de conta

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.

  1. 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.
  2. 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.
  3. Gastar o código apaga sua linha na mesma transação, o que dobra como lock: um segundo confirm em corrida bloqueia, perde e recebe um erro chaveado.
  4. A exclusão em si remove refresh tokens e tokens de reset explicitamente, as seis coleções possuídas pelo cascade do JPA (categorias, hábitos, tarefas, metas, rotinas, snapshots), os chats com a memória de IA, e as linhas de histórico por cascades no nível do banco. Os arquivos em disco são purgados depois do commit, em melhor esforço: os anexos de feedback e a foto de perfil. A foto passou batido no começo, porque as linhas do banco cascateiam e os bytes em disco não, então o JPEG sobrevivia à conta com um nome de arquivo que ainda era o id do usuário apagado.
  5. O cookie de refresh só é limpo após o sucesso, então um código recusado deixa a sessão intacta.

Posse: o modelo de autorização

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.

Endurecimento de uploads

Os dois caminhos de upload (foto de perfil, anexos de feedback) dividem a mesma forma defensiva:

Removendo a foto de perfil

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.

Servindo uma foto de perfil

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)}

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.

Guarda-corpos do agente de IA

O chat do agente chama ferramentas reais, então seu modelo de autoridade importa:

Headers, CORS e guardas de boot

Headers definidos pelo backend em toda resposta:

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.

Avaliação de segurança

O que está bem feito

O que pode melhorar

Á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

Resumo do modelo de ameaças

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