Plano de Fix — #02 ATO (Account Takeover)
Data: 2026-07-07
Severidade: Crítico
Endpoint: POST /api/auth/verify-email
1. Resumo
O chain de ATO permite que um atacante com x-api-key + CPF da vítima troque a senha sem acesso ao e-mail/telefone cadastrado:
POST verify-email {cpf:"vitima", email:"atacante@x.com"} ← email arbitrário no body
→ OTP enviado para atacante@x.com ← sem JWT, só x-api-key
POST verify-code {cpf:"vitima", verificationCode:"123456"} ← mesmo x-api-key
→ token salvo em VerificationCodeModelInCache-{cpf} ← MESMA chave do recover
POST change-password {cpf:"vitima", token, password:"hack"} ← mesmo x-api-key
→ senha da vítima alterada ← ATO completo
2. Causa Raiz (3 vulnerabilidades encadeadas)
| # | Vulnerabilidade | Detalhe |
|---|---|---|
| 1 | verify-email/verify-phone aceitam contato arbitrário | Body email/cellPhone vai direto como destinatário. Sem DB-lookup do contato cadastrado. |
| 2 | verify-email/verify-phone/verify-code sem JWT | Protegidos só por x-api-key. No fluxo update-contact o app ENVIA JWT, mas o backend não EXIGE. |
| 3 | Cache key compartilhada | verify-code (não-recover) e change-password usam a mesma chave VerificationCodeModelInCache-{cpf}. |
3. Decisões de Design
| Decisão | Escolha | Motivo |
|---|---|---|
| verify-email/phone auth | Exigir JWT | Já enviado pelo app no fluxo update-contact |
| Cache key | Separar UpdateContactToken vs VerificationCodeModelInCache | Impede token de update-contact ser usado em change-password |
| Recover sub-flow | Remover do app | Usuário sem contato válido → contatar suporte |
| VerifyCodeEasy | Fix separado | Vulnerabilidade distinta, card próprio |
| Escopo | Backend + análise de mudanças no app | Implementação mobile em card separado |
4. Mudanças no Backend (coezzion-service-auth)
4.1 PasswordController.cs — Adicionar [Authorize(JwtBearer)], remover [UseApiKey]
Arquivo: src/Auth.API/Controllers/PasswordController.cs
Contexto: change-cellphone e change-email já são JWT-only (sem [UseApiKey]). Alinhamos verify-phone, verify-email e verify-code com esse padrão.
Endpoints afetados:
| Action | Antes | Depois |
|---|---|---|
VerifyPhone | [UseApiKey("SendMessage")] | [Authorize(JwtBearer)] |
VerifyEmail | [UseApiKey("SendMessage")] | [Authorize(JwtBearer)] |
VerifyCode (não-recover) | [UseApiKey("SendMessage")] | [Authorize(JwtBearer)] |
// Exemplo — VerifyEmail após mudança:
[HttpPost("/api/auth/verify-email")]
[ProducesResponseType(typeof(VerifyEmailResultModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailInputModel input, ...)
Endpoints que NÃO mudam:
recover-methods/{cpf}— mantém[UseApiKey("SendMessage")]verify-recover-method— mantém[UseApiKey("SendMessage")]verify-code-recover— mantém[UseApiKey("SendMessage")]change-password— mantém[UseApiKey("SendMessage")]change-cellphone— já é[Authorize(JwtBearer)]sem[UseApiKey](sem mudança)change-email— já é[Authorize(JwtBearer)]sem[UseApiKey](sem mudança)
Impacto no app: O VerifyMethodsInterceptor continuará enviando x-api-key para esses paths. O backend ignora — sem breaking change.
4.2 VerifyCodeService.cs — Separar chave de cache
Arquivo: src/Auth.API/Application/Services/Implementations/VerifyCodeService.cs
Problema: verify-code (não-recover, update-contact) e change-password usam a mesma chave VerificationCodeModelInCache-{cpf}. Token de update-contact serve para trocar senha.
Solução: verify-code (não-recover) passa a usar chave UpdateContactToken-{cpf}. verify-code-recover e change-password continuam em VerificationCodeModelInCache-{cpf}.
No VerifyVerificationCodeAsync (usado por verify-code, não-recover):
// Antes:
var key = $"VerificationCodeModelInCache-{input.Cpf}";
// Depois:
var key = $"UpdateContactToken-{input.Cpf}";
Ajustes nos leitores dessa chave — UserService.cs:
| Método | Chave antes | Chave depois |
|---|---|---|
ChangeCellPhone (L120) | VerificationCodeModelInCache-{cpf} | UpdateContactToken-{cpf} |
ChangeEmail (L182) | VerificationCodeModelInCache-{cpf} | UpdateContactToken-{cpf} |
ChangePassword (L46) | VerificationCodeModelInCache-{cpf} | sem mudança |
Resumo:
UpdateContactToken←verify-code(update-contact) →change-cellphone/change-email.
VerificationCodeModelInCache←verify-code-recover(recover) →change-password.
Tokens de fluxos diferentes nunca se cruzam.
4.3 Validar match CPF vs userId autenticado
Contexto: O JWT não possui claim cpf. Usa-se IUserProvider (nuget Coezzion.Common) para extrair dados do usuário autenticado.
Interface: Coezzion.Common.Providers.IUserProvider
public interface IUserProvider
{
int GetUserId(); // ClaimTypes.NameIdentifier → sub
string GetSchemaName();
string GetProfile(); // claim "profile" → ProfileId
}
Lógica de validação:
_userProvider.GetUserId()→userId(int, sem parse manual)_userRepository.GetByIdAsync(userId)→UserModel(já inclui.Include(x => x.Profile))- Se
UserModel.Cpf == input.Cpf→ ok (usuário opera no próprio CPF) - Senão, verifica perfil Admin via
_userProvider.GetProfile():ProfileType.Coezzion_Administrador(1) → permiteProfileType.Administrador(20) → permite- Outros → rejeita
Onde aplicar: Nos 3 endpoints que ganharam [Authorize(JwtBearer)]:
POST verify-phonePOST verify-emailPOST verify-code(não-recover)
Implementação no Controller:
// PasswordController.cs — injetar IUserProvider + IUserRepository
// Método helper para os 3 actions:
private async Task<IActionResult> ValidateCpfOwnership(
string inputCpf,
IUserProvider userProvider,
IUserRepository userRepository)
{
var userId = userProvider.GetUserId();
var profile = userProvider.GetProfile();
var authenticatedUser = await userRepository.GetByIdAsync(userId);
if (authenticatedUser == null)
return Unauthorized("Usuário não encontrado");
if (authenticatedUser.Cpf == inputCpf)
return null; // ok
var profileType = (ProfileType)int.Parse(profile);
if (profileType == ProfileType.Coezzion_Administrador ||
profileType == ProfileType.Administrador)
return null; // admin bypass
return Unauthorized("CPF não corresponde ao token de autenticação");
}
Arquivos novos/alterações:
| Arquivo | Mudança |
|---|---|
src/Auth.API/Controllers/PasswordController.cs | Injetar IUserProvider, adicionar validação nos 3 actions |
src/Auth.API/Data/Repositories/Interfaces/IUserRepository.cs | (sem mudança — GetByIdAsync já existe via IRepository<UserModel>) |
4.4 Restaurar validação de token no ChangeEmail
Arquivo: src/Auth.API/Application/Services/Implementations/UserService.cs (L182-222)
Problema: ChangeEmail não valida o token de verificação no cache — chama UpdateEmailAsync diretamente. Usuário logado pode trocar email sem nunca validar OTP.
Solução: Restaurar validação de token com paridade ao ChangeCellPhone (L120-180):
- Buscar token no cache:
VerificationCodeModelInCache-{cpf} - Validar: token existe, igual ao informado, não expirado
- Validar:
verificationModel.Contact == inputModel.Email(email do cache bate com body) - Remover token do cache após uso
// UserService.ChangeEmail — bloco de validação (igual ChangeCellPhone)
var key = $"UpdateContactToken-{inputModel.Cpf}";
var verificationModel = await _cacheService.GetCacheAsync<VerificationCodeModel>(key);
if (verificationModel == null)
return new UpdateUserEmailResponse().AddError("É necessário validar o email primeiro");
if (verificationModel.Token != inputModel.Token)
return new UpdateUserEmailResponse().AddError("Token inválido");
if (verificationModel.ExpirationDate < DateTime.Now)
return new UpdateUserEmailResponse().AddError("Token expirado, reinicie o processo");
if (verificationModel.Contact != inputModel.Email)
return new UpdateUserEmailResponse().AddError("Email não corresponde ao verificado");
await _cacheService.RemoveAsync(key); // ← estava comentado
4.5 Verificar *Confirmed no VerifyRecoverMethod
Arquivo: src/Auth.API/Application/Services/Implementations/VerifyCodeService.cs (L77-103)
Problema: VerifyRecoverMethodAsync não verifica flags CellPhoneConfirmed/EmailConfirmed. Se um contato existe no banco mas não está confirmado, GetRecoverMethods não o exibe, mas verify-recover-method envia OTP mesmo assim.
Solução: Após GetContactData, verificar flag de confirmação correspondente ao ContactType:
// VerifyCodeService.VerifyRecoverMethodAsync — após GetContactData
var user = await _userRepository.GetCpfAuthAsync(input.Cpf);
if (input.Type == ContactType.Sms && !user.CellPhoneConfirmed)
return new SendCodeVerificationByMethodsResponse().AddError("Telefone não confirmado");
if (input.Type == ContactType.Email && !user.EmailConfirmed)
return new SendCodeVerificationByMethodsResponse().AddError("Email não confirmado");
4.6 Corrigir logging em ValidateVerificationCode
Arquivo: src/Auth.API/Application/Services/Implementations/VerifyCodeService.cs (L203-207)
Problema: Log registra input.Cpf no lugar de input.Contact no terceiro parâmetro:
// Antes (errado):
_logger.LogError(
"Codigo não pertence ao contato informado [{Cpf} | {ContactSave} | {ContactSend}]",
input.Cpf, verificationModel.Contact, input.Cpf);
// Depois (correto):
_logger.LogError(
"Codigo não pertence ao contato informado [{Cpf} | {ContactSave} | {ContactSend}]",
input.Cpf, verificationModel.Contact, input.Contact);
5. Mudanças no App Flutter (análise)
5.1 Remover sub-fluxo de update-contact durante recover
Arquivos afetados:
-
lib/controllers/code_verification/by_method/code_verification_by_method_controller.dart- Método
tryAnotherMethod()— remover navegação paraUpdatePhoneScreen/UpdateEmailScreen - Limite SMS excedido +
hasConfirmedEmail == true: manter opção de trocar para e-mail - Limite SMS excedido +
hasConfirmedEmail == false: exibir mensagem "Contate o suporte"
- Método
-
Possíveis arquivos a remover/limpar:
- Imports de
UpdatePhoneScreen,UpdateEmailScreenno contexto de recover
- Imports de
5.2 Verificar envio de JWT
O app já envia Authorization: Bearer no fluxo update-contact (via customAuthorizationToken). Confirmar que o interceptor VerifyMethodsInterceptor não remove o header Authorization.
Verificar em:
lib/middlewares/http/recover_methods_interceptor.dartlib/shared/utils/dio_utils.dart
6. Verificação de Segurança Pós-Fix
| # | Teste | Esperado |
|---|---|---|
| 1 | POST verify-email sem JWT | 401 Unauthorized |
| 2 | POST verify-phone sem JWT | 401 Unauthorized |
| 3 | POST verify-code (não-recover) sem JWT | 401 Unauthorized |
| 4 | POST verify-email com JWT + CPF de outro usuário | Rejeitado (CPF não bate com userId) |
| 5 | POST verify-email com JWT Admin + CPF de terceiro | Permitido (bypass Admin) |
| 6 | Token de verify-code (update-contact) → change-password | Rejeitado (chave diferente) |
| 7 | POST change-email sem token de verificação no cache | Rejeitado (validação restaurada) |
| 8 | POST change-email com token expirado | Rejeitado |
| 9 | POST verify-recover-method para contato não confirmado | Rejeitado |
| 10 | POST verify-code-recover | Funciona (x-api-key, sem JWT) |
| 11 | POST change-password | Funciona (x-api-key, sem JWT) |
| 12 | POST recover-methods | Funciona normalmente |
7. Fora do Escopo
| Item | Motivo |
|---|---|
| VerifyCodeEasy (backdoor de OTP) | Card separado |
| Rate limit de OTP (#04) | Card separado |
| Enumeração de CPF | Card separado |
| Validators FluentValidation para recover input models | Card separado |
8. Arquivos de Contexto
| Arquivo | Conteúdo |
|---|---|
context/02-ato-analysis.md | Análise detalhada do chain, endpoints, fluxos |
context/grill-decisions.md | Decisões do grill com trade-offs |
context/jwt-claims.md | Estrutura JWT, profiles, IUserProvider |
context/recover.md | Fluxo de recuperação de senha (app) |
context/update-contact.md | Fluxo de atualização de contato (app) |
context/recover-anl.md | Análise do backend de recover (vulnerabilidades) |