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 Cloudflare Tunnel e do ingress do cluster na frente do backend, que não tem porta pública própria. 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 SHA-256, 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/oidc/providers | GET | Não | Os provedores federados configurados; lista vazia quando não há nenhum |
| /auth/oidc/{provider} | POST | Não | Login federado por ID token (web) |
| /auth/oidc/{provider}/mobile | POST | Não | Login federado por ID token (mobile) |
| /auth/oidc/{provider}/link | POST | Sim | Liga uma identidade federada à conta que faz o pedido |
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.
O Google era o único provedor externo, e os dois caminhos dele encontram a conta por e-mail. Isso só é seguro enquanto o único emissor de facto prova a posse do endereço que afirma. Acrescente um segundo provedor com a mesma regra e toda conta do beyou passa a ser alcançável por quem operar aquele emissor: emitir um token afirmando o endereço e entrar na conta, inclusive contas de pessoas que nunca ouviram falar daquele provedor.
Por isso a identidade passou para o par que só o emissor controla e não pode reatribuir,
(issuer, subject), guardado em federated_identities (V29) sob uma restrição UNIQUE. O
endereço afirmado sobrevive como email_at_link, um registro do que foi dito, para suporte
e auditoria. Nada se autentica nele, e não existe de propósito nenhum método de repositório
que encontre um usuário por ele.
FederatedIdentityService.resolve é o único ponto de decisão, e todo provedor passa por
ele:
(iss, sub) conhecido - entra. É a única busca que autentica alguém.LinkRequired(EMAIL_NOT_TRUSTED). Cobre tanto
um provedor em que não confiamos nesse ponto (trustEmailVerified: false, o padrão)
quanto um token que admitiu email_verified: false. Nada é criado, nada é casado, e o
endereço não é nem consultado.LinkRequired(ACCOUNT_EXISTS). A conta
existe; juntar uma porta nova a ela é decisão de quem já está dentro, por
POST /auth/oidc/{provider}/link. Aqui o comportamento difere do Google de
propósito: o Google entra numa conta existente pelo endereço. A razão é o raio de
alcance: quem se cadastrou com senha nunca concordou que um terceiro pudesse abrir a
conta dele.As duas recusas chegam ao cliente como um 403 no envelope de erro padrão: errorKey
FEDERATED_LINK_REQUIRED, com o motivo e o slug do provedor em details. Quase toda
outra recusa da API é um 400 (as chaves de não-encontrado do calendário e do quadro de
Ideias, e os dois 409 do quadro, são as outras exceções, ver
Posse), e esta não é, porque não é uma falha. O cliente
decide por ela e manda a pessoa entrar do jeito de sempre e depois vincular.
trustEmailVerified é por provedor e o padrão é false. Ligá-lo é uma afirmação sobre o
operador daquele emissor, não sobre o código dele: diz que um true ali significa que
alguém provou controlar o endereço, e que ninguém vira a coluna à mão.
A verificação do token (OidcIdTokenVerifier) checa assinatura contra o JWKS publicado
pelo provedor, depois emissor, audience, azp e expiração. O algoritmo é exigido como
RS256 pelo nome em vez de lido do cabeçalho, o que é o que mantém alg: none de fora.
O documento de descoberta é descartado a menos que o issuer dele seja igual ao
configurado: sem isso, redirecionar nossa busca de descoberta deixaria um atacante nomear
o JWKS contra o qual verificamos. As chaves são cacheadas por emissor, e um kid
desconhecido dispara no máximo uma rebusca por minuto, de modo que a rotação se cura sem
deploy e uma sondagem não transforma cada tentativa de login numa requisição externa.
O Google não é migrado em lote: o subject dele nunca foi guardado, então não há de
onde migrar. recordSeenIdentity escreve a linha no próximo login Google da conta, e
falhas ali são engolidas - um login não vale ser derrubado por escrituração, e o caminho
por endereço para o qual ele recai continua correto para o Google especificamente.
Um provedor ausente da configuração não existe: /auth/oidc/providers o omite e os
endpoints de login respondem 400 OIDC_PROVIDER_UNKNOWN. É o interruptor, e não precisa de mudança de código.
| /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.
| 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 SHA-256 do segredo, no formato sha256:<hex>, então uma tabela vazada não contém nada reutilizável.
Por que SHA-256 e não BCrypt. O BCrypt é lento de propósito, para que um segredo adivinhável como uma senha custe tempo real a cada tentativa. Estes segredos são 256 bits aleatórios; não há o que adivinhar, então essa lentidão não protegia nada e custava tempo a todo usuário. O refresh fazia duas operações BCrypt(12) (conferir o segredo antigo, gerar o hash do novo), cerca de 400 ms cada na máquina de produção, o que segurava o POST /auth/refresh perto de 850 ms no p50. Com SHA-256 leva poucos milissegundos. A comparação é em tempo constante.
Linhas gravadas antes da troca guardam hash BCrypt e continuam sendo conferidas com BCrypt, então o deploy não deslogou ninguém. Cada uma é substituída por uma linha SHA-256 na próxima rotação, e a última expira 15 dias depois do deploy.
flowchart TD
CR["🔑 32 bytes aleatórios"] --> HASH["🔒 Hash SHA-256"]
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, POST oidc/{provider} e oidc/{provider}/mobile | 5 / 15 min | IP |
| unsubscribe | POST /notification/unsubscribe | 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 |
| briefing | GET /daily-briefing | 10 / hora | usuário |
| notebook-ai | POST /notebook/ai/** | 60 / hora | usuário |
| notebook-source | POST /notebook/pages//sources/ | 20 / hora | usuário |
| write | qualquer outro POST/PUT/DELETE | 30 / min | usuário |
| read | qualquer outro GET | 60 / min | usuário |
O Resumo do Dia fica acima da faixa de leitura genérica pelo motivo dele: a primeira chamada do dia de um usuário cria uma linha e pode segurar o pedido por até oito segundos à espera do LLM que escreve o texto do resumo, e o orçamento de 60 por minuto foi dimensionado para leituras de lista. Dez por hora cobre dois clientes, um reload e uma repetição, contra uma resposta que fica guardada na linha pelo resto do dia. O irmão POST /daily-briefing/seen fica de propósito na faixa de escrita genérica, por ser uma atualização de uma coluna sem modelo nenhum atrás. O GET /daily-briefing/narrative também fica de fora da faixa, porque a rota do resumo é comparada pelo caminho exato: é o diálogo consultando o texto que chegou depois dos oito segundos, umas dez leituras por chave primária ao longo de um minuto, e com dez por hora ele acabaria já nesse primeiro minuto.
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, dos hábitos e rotinas ao diário, às tarefas do calendário, ao quadro de Ideias, ao caderno, ao histórico de foco, aos resumos do dia e a cada conversa com o assistente, montados em memória e serializados de uma vez. Medido numa conta sintética com um ano de uso pesado, isso dá uns 2,5 MB de JSON montados em menos de meio segundo, e por isso ele continua sendo um pedido comum e não um job em segundo plano. Sessenta por minuto disso ainda é 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.
O caderno de estudos tem duas faixas próprias, as duas checadas antes da faixa de escrita genérica. Toda rota em /notebook/ai/ é uma chamada de modelo: o chat e as saídas da sala de estudo, o rascunho de roadmap, as sugestões, o "explicar" e os flashcards da IA. Uma sessão de estudo é uma sequência de perguntas curtas sobre as mesmas fontes, e dividir os 30 por hora do assistente deixaria uma noite de estudo trancar a pessoa fora do assistente, então o caderno ganha 60, uma pergunta por minuto durante uma hora. Adicionar uma fonte lê um PDF em memória ou busca uma página na internet, e depois lê e embeda em segundo plano, e 20 por hora é uma lista de leitura inteira de uma vez. As rotas do ciclo de estudos, /notebook/cycles/** e /notebook/sessions/**, não têm faixa própria: as leituras gastam o orçamento genérico de leitura e todos os outros métodos o de escrita, inclusive a prévia POST /notebook/cycles/plan do editor, mesmo sem gravar nada.
O login federado divide o bucket de auth porque é o mesmo tipo de porta: anônima, e capaz de criar uma conta. Antes ele caía na faixa de escrita, que deixa passar qualquer pedido sem id de usuário, então não tinha limite nenhum enquanto as rotas do Google ao lado tinham cinco. Só as rotas POST de login contam. O GET /auth/oidc/providers é o que a tela de login lê a cada visita e não pode gastar essas cinco tentativas, e o /link é autenticado, então a faixa de escrita já usa o usuário como chave.
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.users do mesmo jeito: foco, logins vinculados, o resumo do dia, o caderno e os ciclos de estudo dele, os e-mails de engajamento, as tarefas do calendário, as notas do quadro de Ideias. O AccountDeletionIntegrationTest e o teste do runbook leem do schema as tabelas que apontam para users, colocam uma linha em cada, apagam e esperam que nada sobre, então uma tabela nova que esquecer o cascade falha ali. 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.O diário é o único sítio do produto onde alguém escreve longamente, para si próprio, sobre a própria vida. Fica guardado como texto simples numa linha que lhe pertence. Não há cifragem em repouso além da que a base de dados e o disco dão, e dizer o contrário seria pior do que dizer isto. O cuidado está à volta:
mood está na blacklist do persist na
web, e o redux do mobile é só em memória. Uma aba fechada não deixa nada para trás. Um teste lê
a blacklist a partir do ficheiro-fonte, porque a falha é silenciosa: tudo funciona, e o diário
fica simplesmente no localStorage.ServiceMethodsLogging regista contagens de argumentos, nunca
valores.O caderno guarda o segundo tipo de texto pessoal do produto, as anotações de estudo, e traz a única funcionalidade que faz o servidor buscar uma URL digitada por alguém. As duas coisas moldaram a segurança dele.
notebook está na blacklist do persist no web ao lado de mood, e o redux do mobile fica em memória. O slice studyCycles entra na blacklist junto. A exportação leva cada página como texto puro, junto com os quadros, cada revisão de flashcard, o que a sala de estudo produziu e os ciclos de estudo com as sessões lançadas. A exclusão leva as dez tabelas do caderno e as sete do ciclo de estudos pelos cascades a partir de users.NotebookOwnership.page roda antes de qualquer caminho do caderno fazer qualquer coisa, inclusive cards, fontes, saídas e quadros, e também num ciclo de foco que nomeia uma página. Ele lança NOTEBOOK_PAGE_NOT_FOUND ou NOTEBOOK_PAGE_NOT_OWNED, os dois como 400. Um caminho que o pulasse seria um IDOR sobre o texto mais pessoal que o produto guarda, por isso é um método só e não um padrão que cada service repete. Um rascunho de roteiro ainda não é uma página, então o RoadmapDraftService confere o próprio dono e responde NOTEBOOK_DRAFT_NOT_FOUND ou NOTEBOOK_DRAFT_NOT_OWNED; criar um tópico com o draftId de outra pessoa não exclui nada. O notebook-rules.spec.ts confere as duas recusas direto na API.%PDF-, lido em memória pelo PDFBox, e só o texto sobrevive. Não há arquivo em disco, então nada pode ser servido de volta byte a byte e nada fica para trás quando uma conta é apagada. O título é o nome do arquivo sem caminho, e ele nunca toca o filesystem de qualquer forma.LinkFetcher é o único lugar em que o servidor busca uma URL de um usuário. Ele aceita só http e https, com host e sem user-info. Todo endereço para o qual o host resolve precisa ser público: loopback, faixas privadas, link-local (o endereço de metadados da nuvem entre eles), NAT de operadora, multicast, o endereço não especificado e o unique-local do IPv6 são recusados, assim como um endereço IPv6 que embrulhe algum deles. O cliente HTTP não segue redirects. Cada salto é checado à mão, no máximo três, para que uma página pública não consiga desviar a requisição para dentro. Os corpos têm teto enquanto são lidos (3 MB de HTML, 15 MB de PDF), com timeout de 15 segundos. A checagem também roda na hora da requisição, então uma URL recusada responde NOTEBOOK_SOURCE_URL_REFUSED e nunca vira linha. A suíte e2e aponta uma fonte para a porta de gerenciamento e espera essa recusa.LinkFetcher.resolve sob as mesmas regras de um link que a pessoa cola: só endereços públicos, cada salto de redirect checado, e só o suficiente da página lido para o título. Assim um resultado de busca não faz o servidor pedir nada que um link colado não poderia.java.net.http não faz sem abrir mão da checagem de hostname do TLS. A janela é o intervalo entre duas resoluções dentro de uma requisição. Os alvos que importam (a porta de gerenciamento, os metadados da nuvem) não respondem nada útil a um GET que precisa voltar como HTML ou PDF, e a resposta nunca é mostrada crua, só como texto extraído.notebook-ai, as fontes gastam o notebook-source, e uma página guarda 20 fontes próprias.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, NOTEBOOK_PAGE_NOT_OWNED, FOCUS_MICRO_TASK_NOT_OWNED, STUDY_CYCLE_NOT_OWNED). Schedules passam pela rotina dona, o que fechou um IDOR antigo. O caderno de estudos passa todo card, fonte, saída, nó e aresta de quadro pela página a que pertence, via NotebookOwnership. Um ciclo de estudos confere o próprio dono no StudyCycleService, e os blocos dele só são alcançados por ele, enquanto os tópicos e páginas que um ciclo cita recebem as recusas de página do caderno. Uma sessão de estudo lançada que pertence a outra pessoa responde STUDY_SESSION_NOT_FOUND, igual a uma que não existe. Tudo isso aparece como HTTP 400 com errorKey; os clientes discriminam pela chave, não pelo status.
O calendário é feito ao contrário, e foi a primeira exceção a esse 400. CalendarTaskRepository.findByIdAndUserId coloca o dono dentro da busca em vez de conferir depois, então uma tarefa de outra pessoa e um id que ninguém nunca usou recebem a mesma resposta em toda rota (editar, apagar, concluir, confirmar): 404 CALENDAR_TASK_NOT_FOUND. Uma chave NOT_OWNED conta a quem chamou que o id existe; esta resposta não conta nada. O status vem de GlobalExceptionHandler.NOT_FOUND_KEYS, uma lista opcional que tem essa chave e a do quadro de Ideias. Os domínios mais antigos continuam respondendo 400 para as próprias chaves de não-encontrado, porque os clientes web e mobile foram escritos em cima disso, e mudar isso é uma mudança coordenada à parte. Um domínio desenhado daqui em diante coloca a sua chave de não-encontrado na lista desde o começo. O calendar.spec.ts manda as quatro escritas a partir de uma segunda conta e espera o 404 em cada uma.
O e-mail de lembrete do calendário leva o título e a nota da tarefa, que são palavras do próprio usuário. Os dois passam por escape de HTML, o título tem os caracteres de controle achatados antes de virar o cabeçalho de assunto, e um envio que falha registra só ids.
O quadro de Ideias copiou o formato do calendário de propósito. QuickNoteRepository.findByIdAndUserId leva o dono, e o gêmeo com trava também (a conversão em tarefa lê a nota sob uma trava de linha), assim como a leitura em lote por trás de salvar o layout, então uma nota de outra pessoa responde 404 QUICK_NOTE_NOT_FOUND em toda rota. Um layout que cite uma nota dessas é recusado inteiro, e nada se mexe. O quadro também trouxe as primeiras chaves de erro de negócio que respondem 409 (até então só o DATA_CONFLICT respondia, por um handler próprio para uma violação de constraint inesperada), por uma segunda lista opcional ao lado da primeira, CONFLICT_KEYS: um quadro cheio (QUICK_NOTE_LIMIT_REACHED) e uma segunda conversão da mesma nota (QUICK_NOTE_ALREADY_CONVERTED). Os erros de limite mais antigos, do caderno e dos ciclos de estudo, continuam 400 pelo mesmo motivo das chaves de não-encontrado antigas. O quick-notes.spec.ts manda seis escritas a partir de uma segunda conta, espera o 404 em cada uma e confere que essa conta vê um quadro vazio.
Uma nota é texto livre que alguém escreveu para si, então o slice quickNotes fica na blacklist de persistência da web ao lado de mood e notebook, e o mobile o mantém em memória. O assistente trata as notas de outro jeito que o diário: consegue ler as do quadro pelo getQuickNotes, com o texto, porque pegar uma ideia do quadro é a razão de essa ferramenta existir. As notas arquivadas não entram nessa leitura.
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 de imagem (foto de perfil, anexos de feedback) dividem a mesma forma defensiva:
GlobalExceptionHandler escolhe a chave de erro pelo caminho do upload: FEEDBACK_ATTACHMENT_TOO_LARGE, NOTEBOOK_SOURCE_TOO_LARGE ou PHOTO_UPLOAD_TOO_LARGE.O terceiro caminho de upload, um PDF do caderno de estudos, não guarda arquivo nenhum; as regras dele estão na seção do caderno acima.
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: padrões de origem vindos de uma única variável de ambiente, separados por vírgula (a produção lista o web app e o site de documentação), credenciais habilitadas e três headers expostos: X-Access-Token, Retry-After e X-Rate-Limit-Remaining. Os preflights levam Access-Control-Max-Age: 3600; sem isso o navegador guardava um preflight por uns cinco segundos, e mais ou menos uma requisição em cada três em produção era uma ida e volta extra de OPTIONS. 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 de management própria, separada da API, que em produção só o Prometheus e as sondas de uptime podem alcançar (quem garante é uma network policy, não o endereço de bind), com lista fixa de endpoints (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 |
| Fontes de link e DNS rebinding | Todo endereço resolvido é checado, e os redirects são rechecados à mão | A conexão não fica presa ao endereço checado, então um nome que muda entre a checagem e a conexão passa. Os alvos internos úteis não respondem nada que um extrator de texto aproveite |
| Ameaça | Mitigada? | Como |
|---|---|---|
| Roubo de senha em vazamento do banco | Sim | BCrypt custo 12; segredos de reset/exclusão com hash BCrypt, segredos de refresh com hash SHA-256 |
| 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, buscas do calendário e do quadro de Ideias restritas ao dono |
| 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 |
| SSRF pelas fontes de link do caderno | Em grande parte | Só endereços públicos, redirects checados a cada salto, corpos com teto; DNS rebinding é a lacuna documentada |