Skip to main content

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)

#VulnerabilidadeDetalhe
1verify-email/verify-phone aceitam contato arbitrárioBody email/cellPhone vai direto como destinatário. Sem DB-lookup do contato cadastrado.
2verify-email/verify-phone/verify-code sem JWTProtegidos só por x-api-key. No fluxo update-contact o app ENVIA JWT, mas o backend não EXIGE.
3Cache key compartilhadaverify-code (não-recover) e change-password usam a mesma chave VerificationCodeModelInCache-{cpf}.

3. Decisões de Design

DecisãoEscolhaMotivo
verify-email/phone authExigir JWTJá enviado pelo app no fluxo update-contact
Cache keySeparar UpdateContactToken vs VerificationCodeModelInCacheImpede token de update-contact ser usado em change-password
Recover sub-flowRemover do appUsuário sem contato válido → contatar suporte
VerifyCodeEasyFix separadoVulnerabilidade distinta, card próprio
EscopoBackend + análise de mudanças no appImplementaçã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:

ActionAntesDepois
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étodoChave antesChave depois
ChangeCellPhone (L120)VerificationCodeModelInCache-{cpf}UpdateContactToken-{cpf}
ChangeEmail (L182)VerificationCodeModelInCache-{cpf}UpdateContactToken-{cpf}
ChangePassword (L46)VerificationCodeModelInCache-{cpf}sem mudança

Resumo: UpdateContactTokenverify-code (update-contact) → change-cellphone / change-email.
VerificationCodeModelInCacheverify-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:

  1. _userProvider.GetUserId()userId (int, sem parse manual)
  2. _userRepository.GetByIdAsync(userId)UserModel (já inclui .Include(x => x.Profile))
  3. Se UserModel.Cpf == input.Cpf → ok (usuário opera no próprio CPF)
  4. Senão, verifica perfil Admin via _userProvider.GetProfile():
    • ProfileType.Coezzion_Administrador (1) → permite
    • ProfileType.Administrador (20) → permite
    • Outros → rejeita

Onde aplicar: Nos 3 endpoints que ganharam [Authorize(JwtBearer)]:

  • POST verify-phone
  • POST verify-email
  • POST 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:

ArquivoMudança
src/Auth.API/Controllers/PasswordController.csInjetar 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):

  1. Buscar token no cache: VerificationCodeModelInCache-{cpf}
  2. Validar: token existe, igual ao informado, não expirado
  3. Validar: verificationModel.Contact == inputModel.Email (email do cache bate com body)
  4. 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:

  1. lib/controllers/code_verification/by_method/code_verification_by_method_controller.dart

    • Método tryAnotherMethod() — remover navegação para UpdatePhoneScreen / UpdateEmailScreen
    • Limite SMS excedido + hasConfirmedEmail == true: manter opção de trocar para e-mail
    • Limite SMS excedido + hasConfirmedEmail == false: exibir mensagem "Contate o suporte"
  2. Possíveis arquivos a remover/limpar:

    • Imports de UpdatePhoneScreen, UpdateEmailScreen no contexto de recover

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.dart
  • lib/shared/utils/dio_utils.dart

6. Verificação de Segurança Pós-Fix

#TesteEsperado
1POST verify-email sem JWT401 Unauthorized
2POST verify-phone sem JWT401 Unauthorized
3POST verify-code (não-recover) sem JWT401 Unauthorized
4POST verify-email com JWT + CPF de outro usuárioRejeitado (CPF não bate com userId)
5POST verify-email com JWT Admin + CPF de terceiroPermitido (bypass Admin)
6Token de verify-code (update-contact) → change-passwordRejeitado (chave diferente)
7POST change-email sem token de verificação no cacheRejeitado (validação restaurada)
8POST change-email com token expiradoRejeitado
9POST verify-recover-method para contato não confirmadoRejeitado
10POST verify-code-recoverFunciona (x-api-key, sem JWT)
11POST change-passwordFunciona (x-api-key, sem JWT)
12POST recover-methodsFunciona normalmente

7. Fora do Escopo

ItemMotivo
VerifyCodeEasy (backdoor de OTP)Card separado
Rate limit de OTP (#04)Card separado
Enumeração de CPFCard separado
Validators FluentValidation para recover input modelsCard separado

8. Arquivos de Contexto

ArquivoConteúdo
context/02-ato-analysis.mdAnálise detalhada do chain, endpoints, fluxos
context/grill-decisions.mdDecisões do grill com trade-offs
context/jwt-claims.mdEstrutura JWT, profiles, IUserProvider
context/recover.mdFluxo de recuperação de senha (app)
context/update-contact.mdFluxo de atualização de contato (app)
context/recover-anl.mdAnálise do backend de recover (vulnerabilidades)