Skip to content

API de Autenticação

Sistema de autenticação JWT (JSON Web Tokens) com OAuth2 Password Bearer flow.


🔐 Visão Geral

A autenticação na fdplay-api utiliza:

  • JWT (JSON Web Tokens) - Tokens de acesso stateless
  • OAuth2 Password Bearer - Flow de autenticação padrão
  • Argon2 - Hashing de senhas com salt (via passlib)
  • Token Expiration - Tokens expiram após período configurável

📋 Endpoints

Método Endpoint Descrição Auth
POST /token Login (OAuth2 Password Flow) ❌ Não
GET /current-user Dados do token JWT (não do banco) ✅ JWT
POST /auth/change-password Alterar senha (sem precisar do hash) ✅ JWT
POST /auth/forgot-password Solicitar reset de senha (email) ❌ Não
POST /auth/reset-password Redefinir senha (token via email) ❌ Não
POST /auth/resend-verification Reenviar codigo de verificacao de email ❌ Não
POST /auth/refresh-archive-token Renovar archive token (sessao de arquivos) ✅ JWT

Nota: Para dados completos do usuário, use: - Customers: GET /api/v1/customers/me - Admins: GET /api/v1/my-user


1. POST /api/v1/token - Login

Autenticar usuário e receber token de acesso JWT.

Nota: Usa OAuth2 Password Bearer Flow padrão. O endpoint é /token (não /auth/login).

Login por Email ou Username

O campo username aceita email ou username. O backend tenta buscar por username primeiro; se não encontrar e o valor contiver @, faz fallback para busca por email. Isso permite que o frontend envie o email diretamente no campo username.

Headers:

Content-Type: application/x-www-form-urlencoded

Request Body (Form Data):

username=user@example.com&password=SecurePassword123!

Aceita tanto username=nascwane quanto username=nascwane@gmail.com.

Exemplo cURL:

# Login com email
curl -X POST "http://localhost:8000/api/v1/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=user@example.com&password=SecurePassword123!"

# Login com username
curl -X POST "http://localhost:8000/api/v1/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=myusername&password=SecurePassword123!"

Exemplo Flutter/Dart:

import 'package:http/http.dart' as http;

// O frontend pode enviar o email diretamente no campo username
final response = await http.post(
    Uri.parse('http://localhost:8000/api/v1/token'),
    headers: {'Content-Type': 'application/x-www-form-urlencoded'},
    body: 'username=user@example.com&password=SecurePassword123!',
);

Response (200 OK):

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "archive_token": "tqbxvElUHs2e14rQOo-AOd6NXJ7Nk_Ttb3xdwEOzxCk",
  "token_type": "bearer",
  "id_token": "507f1f77bcf86cd799439011",
  "expiration": "2026-01-19T04:30:00Z"
}
Campo Tipo Descrição
access_token string Token JWT para endpoints autenticados (header Authorization: Bearer ...)
archive_token string Token de sessão para acesso público a arquivos do GridFS (thumbnails, imagens, documentos)
token_type string Sempre "bearer"
id_token string ObjectId do usuário
expiration string Data/hora de expiração (ambos os tokens expiram juntos)

Archive Token — Acesso público a arquivos

O archive_token permite acessar qualquer arquivo do GridFS sem precisar do JWT. Ideal para tags <img>, <video> e downloads onde não é possível enviar o header Authorization.

Como usar:

GET /api/v1/archive-records-public/{archive_token}?f={file_id}

Exemplo prático (thumbnail):

GET /api/v1/archive-records-public/tqbxvElUHs2e14rQOo-AOd6NXJ7Nk_Ttb3xdwEOzxCk?f=69bea6244a2410cd09d8c210

Veja a seção Archive Token — Acesso Público a Arquivos para detalhes completos.

Sincronização de Assinatura no Login

Para customers com assinatura ativa, o backend realiza automaticamente uma verificação do status de pagamento junto ao gateway de pagamento durante o login.

  • Se o status no gateway divergir do local (ex: SUSPENDED, CANCELLED), o banco é atualizado imediatamente.
  • Essa sincronização é best-effort: se o gateway estiver indisponível, o login completa normalmente.
  • Impacto para o frontend: Após o login bem-sucedido, o status de assinatura retornado por GET /customers/me/subscription está garantidamente atualizado.
  • Admins e customers sem assinatura não são afetados (nenhuma chamada extra).

Response (401 - Usuário Legado com Senha Placeholder):

Usuários migrados do sistema SQL anterior possuem senha placeholder. O login falha com código específico PASSWORD_RESET_REQUIRED, diferente do INVALID_CREDENTIALS de senha errada normal.

{
  "error": {
    "code": "PASSWORD_RESET_REQUIRED",
    "message": "Password reset required. Use POST /auth/forgot-password with your email to set a new password.",
    "timestamp": "2026-03-08T12:00:00Z"
  }
}

Frontend DEVE diferenciar os dois erros 401

Código Significado Ação do frontend
INVALID_CREDENTIALS Senha errada (user normal) Exibir "Email ou senha incorretos"
PASSWORD_RESET_REQUIRED Usuário legado sem senha definida Redirecionar para POST /auth/forgot-password → reset via email

Response (200 OK - Usuário Legado que JÁ definiu senha mas ainda tem flag):

Caso raro: legado que definiu senha via admin mas flag não foi limpa. Login funciona, mas retorna flag.

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "id_token": "507f1f77bcf86cd799439011",
  "expiration": "2026-01-19T04:30:00Z",
  "password_reset_required": true,
  "message": "Password reset required. Please change your password using POST /api/v1/auth/change-password before accessing other resources."
}

Tratamento no frontend para ambos os cenários

  1. 401 PASSWORD_RESET_REQUIRED → Redirecionar para forgot-password (legado sem senha)
  2. 200 com password_reset_required: true → Redirecionar para change-password (legado que sabe a senha)

Veja seção Fluxo de Reset de Senha Obrigatório abaixo.

Response (401 Unauthorized - Credenciais Inválidas):

{
  "detail": "Access Denied: Incorrect username or password."
}

2. GET /api/v1/current-user - Dados do Token Atual

Retorna dados extraídos do JWT token do usuário autenticado.

Nota: Este endpoint retorna apenas dados do token JWT, não do banco de dados. Para dados completos do usuário, use /customers/me (customers) ou /my-user (admins).

Headers:

Authorization: Bearer <access_token>

Response (200 OK):

{
  "user_id": "507f1f77bcf86cd799439011",
  "username": "joao_silva",
  "type_user": ["677f1f77bcf86cd799439099"],
  "tz": "UTC"
}
Campo Tipo Descrição
user_id string (ObjectId) ID do usuário no MongoDB
username string \| null Nome de usuário (login)
type_user array[string] Lista de IDs de perfis em users_type (vazio para customers)
tz string Timezone do token (default: "UTC")

⚠️ Importante: Para obter dados completos do usuário (email, nome, CPF, assinatura, etc.), use: - Customers: GET /api/v1/customers/me - Admins: GET /api/v1/my-user


3. POST /api/v1/auth/change-password - Alterar Senha

Alterar senha do usuário autenticado (Admin ou Customer).

Este endpoint não requer o hash atual da senha (diferente de PUT /users/me ou PUT /customers/me). Também limpa automaticamente a flag password_reset_required para usuários migrados do sistema legado.

Headers:

Authorization: Bearer <access_token>
Content-Type: application/json

Request Body:

{
  "current_password": "senha_atual_123",
  "new_password": "nova_senha_segura_456"
}
Campo Tipo Obrigatório Validação Descrição
current_password string ✅ Sim mínimo 1 char Senha atual
new_password string ✅ Sim mínimo 8 chars Nova senha

Exemplo cURL:

curl -X POST "http://localhost:8000/api/v1/auth/change-password" \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{
    "current_password": "senha_atual_123",
    "new_password": "nova_senha_segura_456"
  }'

Response (200 OK):

{
  "message": "Password changed successfully",
  "password_reset_required": false
}

Response (400 Bad Request - Senha Atual Incorreta):

{
  "detail": "Current password is incorrect"
}

Response (404 Not Found - Usuário Não Encontrado):

{
  "detail": "User not found"
}

4. POST /api/v1/auth/forgot-password - Solicitar Reset de Senha

Solicitar envio de código de 6 dígitos para redefinição de senha por email.

Este endpoint é público (não requer autenticação). Por segurança, sempre retorna a mesma resposta independente de o email existir ou não no sistema (previne enumeração de emails).

Headers:

Content-Type: application/json

Request Body:

{
  "email": "joao@example.com"
}
Campo Tipo Obrigatório Descrição
email string ✅ Sim Email associado à conta do customer

Exemplo cURL:

curl -X POST "http://localhost:8000/api/v1/auth/forgot-password" \
  -H "Content-Type: application/json" \
  -d '{"email": "joao@example.com"}'

Exemplo Flutter/Dart:

final response = await http.post(
  Uri.parse('$baseUrl/api/v1/auth/forgot-password'),
  headers: {'Content-Type': 'application/json'},
  body: jsonEncode({'email': 'joao@example.com'}),
);

Response (200 OK) — Sempre a mesma resposta:

{
  "message": "Se o email estiver cadastrado, voce recebera as instrucoes."
}

Response (429 Too Many Requests — Cooldown de Reenvio):

{
  "detail": "Aguarde 45 segundos para reenviar o codigo."
}

Anti-Enumeração de Emails

A resposta é idêntica para emails existentes e inexistentes. O frontend não deve tentar diferenciar os dois casos.

  • Email existe → código gerado + email enviado + 200
  • Email não existe → nenhuma ação + 200

Comportamento do Código

  • O código é um número aleatório de 6 dígitos com expiração de 15 minutos
  • Um sha256(code) é armazenado no campo password_reset_code_hash do customer
  • Se um novo pedido for feito, o código anterior é invalidado (hash sobrescrito)
  • Rate limit: 1 reenvio a cada 60 segundos por email (resposta 429 se antes)
  • O envio usa a Resend API (ResendClient) como metodo primario para enviar o email com o codigo
  • Se o envio falhar, o codigo e logado no console do servidor (fallback para debug)

5. POST /api/v1/auth/reset-password - Redefinir Senha

Redefinir senha usando código de 6 dígitos recebido via email.

Este endpoint é público (não requer autenticação). O código é validado pelo hash SHA-256 armazenado no MongoDB (garantia single-use).

Validação Automática de Email

Ao redefinir a senha com sucesso, o campo email_verified é automaticamente marcado como true. A lógica: se o usuário recebeu o código por email e o validou corretamente, prova que tem acesso ao email. Isso evita redirecionamento para onboarding após login.

Headers:

Content-Type: application/json

Request Body:

{
  "email": "joao@example.com",
  "code": "847291",
  "new_password": "minha_nova_senha_segura"
}
Campo Tipo Obrigatório Validação Descrição
email string ✅ Sim Email associado à conta
code string ✅ Sim exatamente 6 dígitos (^\d{6}$) Código de 6 dígitos recebido no email
new_password string ✅ Sim mínimo 8 chars Nova senha

Exemplo cURL:

curl -X POST "http://localhost:8000/api/v1/auth/reset-password" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "joao@example.com",
    "code": "847291",
    "new_password": "minha_nova_senha_segura"
  }'

Exemplo Flutter/Dart:

final response = await http.post(
  Uri.parse('$baseUrl/api/v1/auth/reset-password'),
  headers: {'Content-Type': 'application/json'},
  body: jsonEncode({
    'email': 'joao@example.com',
    'code': '847291',
    'new_password': 'minha_nova_senha_segura',
  }),
);

Response (200 OK):

{
  "message": "Senha redefinida com sucesso."
}

Response (400 Bad Request — Código Expirado):

{
  "detail": "Codigo expirado. Solicite um novo codigo."
}

Response (400 Bad Request — Código Inválido):

{
  "detail": "Codigo invalido. Tentativas restantes: 3"
}

Response (429 Too Many Requests — Tentativas Esgotadas):

{
  "detail": "Tentativas esgotadas. Solicite um novo codigo."
}

Response (404 Not Found — Usuário Não Encontrado):

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Customer not found"
  }
}

Tentativas e Expiração

  • Máximo de 5 tentativas por código — após esgotar, é necessário solicitar novo código via POST /auth/forgot-password
  • Código expira em 15 minutos após o envio
  • Ao redefinir com sucesso, todos os campos password_reset_code_* são removidos do documento

6. POST /api/v1/auth/resend-verification - Reenviar Codigo de Verificacao

Reenviar codigo de verificacao de email para customer com cadastro pendente.

Este endpoint e publico (nao requer autenticacao). Por seguranca, sempre retorna a mesma resposta independente de o email existir ou nao no sistema (previne enumeracao de emails).

Quando usar este endpoint

Use este endpoint quando o customer:

  • Nao recebeu o codigo de verificacao
  • Precisa de um novo codigo (codigo expirado ou tentativas esgotadas)
  • Quer reenviar sem precisar repetir todos os campos de cadastro

Alternativa: Reenviar pelo proprio POST /customers/signup (sem verification_code) tambem funciona, mas exige todos os campos do cadastro.

Headers:

Content-Type: application/json

Request Body:

{
  "email": "cliente.teste@example.com"
}
Campo Tipo Obrigatorio Descricao
email string Sim Email do customer com cadastro pendente (nao verificado)

Exemplo cURL:

curl -X POST "http://localhost:8000/api/v1/auth/resend-verification" \
  -H "Content-Type: application/json" \
  -d '{"email": "cliente.teste@example.com"}'

Exemplo Flutter/Dart:

final response = await http.post(
  Uri.parse('$baseUrl/api/v1/auth/resend-verification'),
  headers: {'Content-Type': 'application/json'},
  body: jsonEncode({'email': 'cliente.teste@example.com'}),
);

Response (200 OK) — Sempre a mesma resposta:

{
  "message": "Se o email estiver cadastrado e pendente, o codigo sera reenviado.",
  "expires_in_minutes": 15
}

Anti-Enumeracao de Emails

A resposta e identica para emails existentes e inexistentes. O frontend nao deve tentar diferenciar os dois casos.

  • Email existe e pendente -> novo codigo gerado + email enviado + 200
  • Email nao existe -> nenhuma acao + 200
  • Email ja verificado -> nenhuma acao + 200

Response (429 Too Many Requests — Rate Limited):

{
  "detail": "Aguarde 45 segundos para reenviar o codigo."
}

Rate Limiting

  • 1 reenvio a cada 60 segundos por email
  • O codigo anterior e invalidado quando um novo e gerado
  • Novo codigo expira em 15 minutos
  • Contador de tentativas e resetado (volta para 0 de 5)

7. POST /api/v1/auth/refresh-archive-token - Renovar Archive Token

Gerar um novo archive token para o usuario autenticado.

Este endpoint e protegido (requer JWT). Use quando o archive_token atual expirou ou esta ausente apos restoreSession().

Headers:

Authorization: Bearer <access_token>

Exemplo cURL:

curl -X POST "http://localhost:8000/api/v1/auth/refresh-archive-token" \
  -H "Authorization: Bearer eyJhbGci..."

Response (200 OK):

{
  "archive_token": "tqbxvElUHs2e14rQOo-AOd6NXJ7Nk_Ttb3xdwEOzxCk",
  "archive_token_expires_at": "2026-03-28T15:30:00+00:00",
  "expires_in_minutes": 60
}
Campo Tipo Descricao
archive_token string Novo token de sessao para acesso publico a arquivos do GridFS
archive_token_expires_at string (ISO) Data/hora UTC de expiracao do archive token
expires_in_minutes int Validade do token em minutos

Response (404 Not Found):

{
  "error": {
    "code": "NOT_FOUND",
    "message": "User not found."
  }
}

🔑 Fluxo de Recuperação de Senha (Forgot Password)

Fluxo completo para customers que esqueceram a senha. Não requer autenticação.

Arquitetura de Envio de Email

Os endpoints de autenticacao (forgot-password, reset-password, resend-verification) utilizam a Resend API (ResendClient) como metodo primario de envio de email.

Fluxo Servico Observacao
Forgot Password Resend API ResendClient.send_password_reset_code()
Resend Verification Resend API ResendClient.send_verification_code()
Signup (verificacao) Resend API ResendClient.send_verification_code()
Change Email Resend API ResendClient.send_email_change_code()

Se o envio via Resend falhar, o codigo e logado no console do servidor como fallback para debug.

Fluxo Completo

sequenceDiagram
    participant Frontend
    participant API
    participant MongoDB
    participant Email

    Frontend->>API: POST /auth/forgot-password {email}
    API->>MongoDB: Buscar customer por email
    alt Email encontrado
        API->>API: Verificar cooldown (60s)
        API->>API: Gerar código 6 dígitos
        API->>MongoDB: Salvar sha256(code), expiry, attempts=0
        API->>Email: Enviar código por email
    else Email não encontrado
        Note over API: Nenhuma ação (anti-enumeração)
    end
    API-->>Frontend: 200 {message} (sempre igual)

    Note over Frontend: Exibir tela de inserção do código

    Frontend->>Frontend: Usuário digita código do email

    Frontend->>API: POST /auth/reset-password {email, code, new_password}
    API->>MongoDB: Buscar customer por email
    API->>API: Verificar tentativas (max 5)
    API->>API: Verificar expiração (15 min)
    API->>API: Comparar sha256(code) com hash armazenado
    alt Código válido
        API->>API: Hash nova senha (Argon2)
        API->>MongoDB: Atualizar password + $unset campos password_reset_code_*
        API-->>Frontend: 200 {message: "Senha redefinida com sucesso."}
    else Código expirado
        API-->>Frontend: 400 "Codigo expirado"
    else Código inválido
        API->>MongoDB: Incrementar attempts
        API-->>Frontend: 400 "Codigo invalido. Tentativas restantes: N"
    else Tentativas esgotadas (5x)
        API-->>Frontend: 429 "Tentativas esgotadas"
    end

Exemplo Flutter/Dart

import 'dart:convert';
import 'package:http/http.dart' as http;

class PasswordResetService {
  static const String baseUrl = 'https://fdplay-api.infraifd.com/api/v1';

  /// Solicita reset de senha (envia email com código de 6 dígitos).
  ///
  /// Sempre retorna sucesso para prevenir enumeração de emails.
  /// Rate limit: 1 reenvio a cada 60 segundos (429 se antes).
  Future<String> forgotPassword(String email) async {
    final response = await http.post(
      Uri.parse('$baseUrl/auth/forgot-password'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'email': email}),
    );

    if (response.statusCode == 429) {
      final body = jsonDecode(response.body);
      throw Exception(body['detail'] ?? 'Aguarde para reenviar o código.');
    }

    final body = jsonDecode(response.body);
    return body['message'];
  }

  /// Redefine a senha usando o código de 6 dígitos recebido via email.
  ///
  /// Lança [Exception] com mensagem se o código for inválido, expirado
  /// ou se as tentativas foram esgotadas.
  Future<String> resetPassword({
    required String email,
    required String code,
    required String newPassword,
  }) async {
    final response = await http.post(
      Uri.parse('$baseUrl/auth/reset-password'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'email': email,
        'code': code,
        'new_password': newPassword,
      }),
    );

    final body = jsonDecode(response.body);

    if (response.statusCode == 200) {
      return body['message'];
    }

    // Tratar erros específicos
    if (response.statusCode == 429) {
      throw Exception(body['detail'] ?? 'Tentativas esgotadas. Solicite um novo código.');
    }

    throw Exception(body['detail'] ?? 'Erro ao redefinir senha');
  }
}

🔄 Fluxo de Reset de Senha Obrigatório (Usuários Legados)

Usuários migrados do sistema SQL legado possuem password_reset_required: true e devem alterar a senha no primeiro login.

Fluxo Completo

sequenceDiagram
    participant Frontend
    participant API
    participant MongoDB

    Frontend->>API: POST /token (username + password)
    API->>MongoDB: Buscar usuário
    MongoDB-->>API: Usuário com password_reset_required=true
    API-->>Frontend: 200 OK + password_reset_required: true

    Note over Frontend: Exibir modal/tela de troca de senha obrigatória

    Frontend->>Frontend: Usuário digita nova senha
    Frontend->>API: POST /auth/change-password
    Note over Frontend: Authorization: Bearer <token>
    API->>API: Verificar senha atual
    API->>API: Hash nova senha (Argon2)
    API->>MongoDB: Atualizar password + password_reset_required=false
    API-->>Frontend: 200 OK

    Note over Frontend: Liberar acesso às funcionalidades

Exemplo Flutter/Dart

import 'dart:convert';
import 'package:http/http.dart' as http;

class AuthService {
  final String baseUrl;
  String? _token;
  String? _tempPassword;
  bool requiresPasswordReset = false;

  AuthService({required this.baseUrl});

  /// Login com verificação de reset obrigatório
  Future<Map<String, dynamic>> login(String username, String password) async {
    final response = await http.post(
      Uri.parse('$baseUrl/api/v1/token'),
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: 'username=$username&password=$password',
    );

    if (response.statusCode != 200) {
      final error = jsonDecode(response.body);
      throw Exception(error['detail'] ?? 'Login failed');
    }

    final data = jsonDecode(response.body);
    _token = data['access_token'];

    // Verificar se precisa trocar senha
    if (data['password_reset_required'] == true) {
      requiresPasswordReset = true;
      _tempPassword = password;
      return {'requiresPasswordReset': true, 'message': data['message']};
    }

    // Login normal: persistir token
    await _persistToken(_token!);
    return {'requiresPasswordReset': false};
  }

  /// Trocar senha (usado após login com password_reset_required)
  Future<void> changePassword(String newPassword) async {
    if (_token == null || _tempPassword == null) {
      throw Exception('Invalid state: no token or temp password');
    }

    final response = await http.post(
      Uri.parse('$baseUrl/api/v1/auth/change-password'),
      headers: {
        'Authorization': 'Bearer $_token',
        'Content-Type': 'application/json',
      },
      body: jsonEncode({
        'current_password': _tempPassword,
        'new_password': newPassword,
      }),
    );

    if (response.statusCode != 200) {
      final error = jsonDecode(response.body);
      throw Exception(error['detail'] ?? 'Password change failed');
    }

    // Sucesso: limpar estado e persistir token
    requiresPasswordReset = false;
    _tempPassword = null;
    await _persistToken(_token!);
  }

  Future<void> _persistToken(String token) async {
    // Usar secure storage em produção
    // await secureStorage.write(key: 'access_token', value: token);
  }
}

🔑 JWT Token

Estrutura do Token

JWT é composto por 3 partes separadas por .:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7InVzZXJfaWQiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImpvYW9fc2lsdmEiLCJ0eXBlX3VzZXIiOltdfSwiZXhwIjoxNzA0NzIwMDAwLCJ0eiI6IlVUQyJ9.signature
^                                        ^                                                                                                                                          ^
Header (algoritmo)                     Payload (claims)                                                                                                                        Signature

Payload (Decoded)

{
  "data": {
    "user_id": "507f1f77bcf86cd799439011",
    "username": "joao_silva",
    "type_user": ["677f1f77bcf86cd799439099"]
  },
  "exp": 1704720000,
  "iat": 1704716400,
  "tz": "UTC"
}
Claim Tipo Descrição
data.user_id string ID do usuário (MongoDB ObjectId)
data.username string Nome de usuário (login)
data.type_user array[string] IDs de perfis em users_type (vazio para customers sem perfil)
exp int Timestamp de expiração (Unix epoch)
iat int Timestamp de emissão (Unix epoch)
tz string Timezone usado na geração do token

Validação de Token

import jwt
from fastapi import HTTPException, status

def decode_access_token(token: str, secret_key: str, algorithm: str) -> TokenData:
    """
    Decodificar e validar JWT token.

    Valida:
    - Assinatura (algoritmo configurado em settings.JWT_ALGORITHM)
    - Expiração (exp claim)
    - Estrutura (data.user_id e data.username obrigatórios)
    """
    try:
        payload = jwt.decode(
            token,
            secret_key,
            algorithms=[algorithm]
        )

        data = payload.get('data') or {}
        user_id: str | None = data.get('user_id')
        username: str | None = data.get('username')
        type_user: list = data.get('type_user') or []
        tz: str = payload.get('tz', 'UTC')

        if user_id is None or username is None:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail='Access Denied: Could not validate token.'
            )

        return TokenData(
            user_id=user_id,
            username=username,
            type_user=type_user,
            tz=tz
        )

    except jwt.PyJWTError:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail='Access Denied: Could not validate token.',
        )

🔐 Hashing de Senhas

Argon2 com salt automático (via passlib):

from passlib.context import CryptContext

pwd_context = CryptContext(schemes=['argon2'], deprecated='auto')

def hash_password(password: str) -> str:
    """Gerar hash Argon2 da senha."""
    return pwd_context.hash(password)

def verify_password(plain_password: str, hashed_password: str) -> bool:
    """Verificar senha contra hash Argon2."""
    return pwd_context.verify(plain_password, hashed_password)

Exemplo:

# Signup (criar usuário)
hashed_password = hash_password("SecurePassword123!")
# Resultado: "$2b$12$abc123..."

# Login (verificar senha)
is_valid = verify_password("SecurePassword123!", user.password_hash)
# Resultado: True

🎯 Fluxo de Autenticação Completo

Login Flow

sequenceDiagram
    participant Client
    participant API
    participant Auth
    participant DB
    participant JWT

    Client->>API: POST /api/v1/token<br/>username + password
    API->>Auth: authenticate_user()
    Auth->>DB: Buscar user por email
    DB-->>Auth: User document

    alt User Not Found
        Auth-->>Client: 401 Incorrect email or password
    else User Found
        Auth->>Auth: verify_password(plain, hashed)
        alt Password Invalid
            Auth-->>Client: 401 Incorrect email or password
        else Password Valid
            alt User Inactive (enable=false)
                Auth-->>Client: 403 User account is inactive
            else User Active
                Auth->>JWT: create_access_token(user_id, user_type)
                JWT-->>Auth: JWT token
                Auth-->>Client: 200 OK {access_token, token_type}
            end
        end
    end

Protected Endpoint Flow

sequenceDiagram
    participant Client
    participant API
    participant Auth
    participant JWT
    participant DB

    Client->>API: GET /api/v1/videos<br/>Authorization: Bearer <token>
    API->>Auth: get_current_user(token)
    Auth->>JWT: decode_access_token(token)

    alt Token Invalid/Expired
        JWT-->>Client: 401 Could not validate credentials
    else Token Valid
        JWT-->>Auth: TokenData(user_id, user_type)
        Auth->>DB: Buscar user por ID
        DB-->>Auth: User document

        alt User Not Found
            Auth-->>Client: 401 User not found
        else User Found
            Auth-->>API: TokenData
            API->>API: Execute endpoint logic
            API-->>Client: 200 OK + response
        end
    end

🔒 Sistema de Permissões (users_type)

Além da autenticação JWT, o backend implementa um sistema de autorização baseado em perfis armazenados na collection users_type.

Arquitetura de Permissões

flowchart TD
    A[Request Autenticada] --> B[Decodifica JWT]
    B --> C{Token válido?}
    C -->|Não| D[401 Unauthorized]
    C -->|Sim| E[Extrai type_user do payload]
    E --> F{type_user vazio?}
    F -->|Sim| G{É rota bypass?}
    F -->|Não| H[Busca permissões no DB]
    G -->|Sim| I[✅ Acesso liberado]
    G -->|Não| J[❌ 401 Access Denied]
    H --> K{Tem permissão para método?}
    K -->|GET → view| L[Verifica lista view]
    K -->|POST → create| M[Verifica lista create]
    K -->|PUT → update| N[Verifica lista update]
    K -->|DELETE → delete| O[Verifica lista delete]
    L --> P{route_name na lista?}
    M --> P
    N --> P
    O --> P
    P -->|Sim| I
    P -->|Não| J

Estrutura do Token JWT

O payload do JWT contém o campo type_user:

{
  "data": {
    "username": "joao_silva",
    "user_id": "507f1f77bcf86cd799439011",
    "type_user": ["677f1f77bcf86cd799439099", "677f1f77bcf86cd799439100"]
  },
  "exp": 1704720000,
  "iat": 1704716400,
  "tz": "America/Sao_Paulo"
}
Campo Tipo Descrição
type_user list[ObjectId] IDs dos perfis em users_type associados ao usuário

Collection users_type

Cada perfil define permissões por rota:

{
  "_id": "677f1f77bcf86cd799439099",
  "name": "admin_full",
  "view": ["customer_get", "video_get", "plan_get"],
  "create": ["customer_post", "video_post", "plan_post"],
  "update": ["customer_put", "video_put", "plan_put"],
  "delete": ["customer_delete", "video_delete", "plan_delete"],
  "users": ["507f1f77bcf86cd799439011"]
}

Rotas com Bypass de Permissão

Problema: Customers criados via POST /customers/signup não são associados a nenhum perfil em users_type. Resultado: type_user: [] no token.

Solução: Rotas de self-service têm bypass de verificação de permissão:

# application/adapters/controllers/auth.py
bypass_routes = {
    'my_user_get',            # GET    /my-user
    'my_user_put',            # PUT    /my-user
    'customer_subscribe',     # POST   /customers/me/subscribe
    'renew_my_subscription',  # PUT    /customers/me/subscription/renew
    'my_customer_get',        # GET    /customers/me
    'my_customer_put',        # PUT    /customers/me
    'customer_delete_self',   # DELETE /customers/me
    'video_get',              # GET    /videos (autorizado via subscription middleware)
    'change_password',        # POST   /auth/change-password
    'upload_avatar',          # POST   /me/avatar
    'delete_avatar',          # DELETE /me/avatar
    'change_email',           # POST   /customers/me/change-email
    'confirm_email',          # POST   /customers/me/confirm-email
    'category_get',           # GET    /categories
}
Rota Função Método Por que bypass?
/customers/me/subscribe customer_subscribe POST Customer cria própria assinatura
/customers/me/subscription/renew renew_my_subscription PUT Customer troca método de pagamento
/customers/me my_customer_get GET Customer vê próprios dados
/customers/me my_customer_put PUT Customer atualiza próprios dados
/customers/me customer_delete_self DELETE Customer deleta própria conta
/videos video_get GET Autorizado via subscription middleware
/auth/change-password change_password POST Customer/Admin altera própria senha
/me/avatar upload_avatar POST Customer/Admin faz upload de avatar
/me/avatar delete_avatar DELETE Customer/Admin remove avatar
/customers/me/change-email change_email POST Customer solicita troca de email
/customers/me/confirm-email confirm_email POST Customer confirma troca de email
/categories category_get GET Listagem pública de categorias
/my-user my_user_get GET Qualquer usuário vê próprios dados
/my-user my_user_put PUT Qualquer usuário atualiza próprios dados

Fluxo Completo: Customer sem Perfil

sequenceDiagram
    participant Customer
    participant API
    participant Auth
    participant DB

    Customer->>API: POST /customers/signup
    API->>DB: Cria usuário (user_type="customer")
    DB-->>API: Usuário criado
    API->>Auth: generate_token(user)
    Note over Auth: type_user: [] (vazio, sem perfil)
    Auth-->>Customer: JWT token

    Customer->>API: POST /customers/me/subscribe<br/>Authorization: Bearer <token>
    API->>Auth: get_current_user(token)
    Auth->>Auth: Decodifica JWT → type_user: []
    Auth->>Auth: route_name = "customer_subscribe"
    Auth->>Auth: "customer_subscribe" in bypass_routes?
    Note over Auth: ✅ SIM! Bypass ativado
    Auth-->>API: TokenData(user_id, type_user=[])
    API->>DB: Cria assinatura
    DB-->>API: Assinatura criada
    API-->>Customer: 200 OK

Código de Verificação

# application/adapters/controllers/auth.py (simplificado)

async def get_current_user(self, request: Request) -> TokenData:
    # ... decode JWT ...

    type_user: list = data.get('type_user') or []

    if username != 'root':
        endpoint = request.scope.get('endpoint')
        route_name = getattr(endpoint, '__name__', None)

        permissions = await self.auth_interactor.get_permissions(list_id=type_user)

        # Bypass para rotas de self-service
        bypass_routes = {
            'my_user_get', 'my_user_put',
            'customer_subscribe', 'renew_my_subscription',
            'my_customer_get', 'my_customer_put', 'customer_delete_self',
            'video_get', 'change_password',
            'upload_avatar', 'delete_avatar',
            'change_email', 'confirm_email', 'category_get',
        }

        if route_name not in bypass_routes:
            match request.method:
                case 'POST':
                    if not any(route_name in p for p in permissions['create']):
                        raise HTTPException(
                            status_code=401,
                            detail='Access Denied: You do not have permission for create.'
                        )
                # ... outros métodos ...

    return TokenData(user_id=..., username=..., type_user=type_user)

Erros Relacionados

401 "Access Denied: You do not have permission for create"

Causa: Token com type_user: [] tentando acessar rota fora do bypass.

Debug:

# Decodificar token para ver type_user
import jwt
payload = jwt.decode(token, options={"verify_signature": False})
print(payload['data']['type_user'])  # Se [], usuário não tem perfil

Soluções:

  1. Usar rota com bypass (/customers/me/subscribe ao invés de rota admin)
  2. Associar usuário a um perfil em users_type
  3. Usar conta admin para operações administrativas

401 "Access Denied: Could not resolve endpoint"

Causa: FastAPI não conseguiu resolver o nome da função do endpoint.

Solução: Bug interno. Reportar com stack trace completo.


🛡️ Proteção de Rotas

Dependency Injection

FastAPI usa dependencies para proteger rotas:

from fastapi import Depends
from application.adapters.controllers.auth import AuthControl

auth_control = AuthControl(entities)

@router.get('/api/v1/protected-endpoint')
async def protected_endpoint(
    auth: TokenData = Depends(auth_control.get_current_user)
):
    """
    Endpoint protegido por autenticação JWT.

    auth: Dados do usuário autenticado (user_id, user_type)
    """
    # auth.user_id → ID do usuário
    # auth.user_type → 'customer' ou 'admin'

    return {'message': f'Hello, user {auth.user_id}'}

Proteção por Tipo de Usuário

def require_admin(auth: TokenData = Depends(auth_control.get_current_user)):
    """Dependency que exige user_type='admin'."""
    if auth.user_type != 'admin':
        raise HTTPException(
            status_code=403,
            detail='Admin access required'
        )
    return auth

@router.post('/api/v1/admin-only-endpoint')
async def admin_endpoint(
    auth: TokenData = Depends(require_admin)
):
    """Apenas admins podem acessar."""
    return {'message': 'Admin access granted'}

📱 Implementação Frontend

Login e Armazenamento de Token

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class LoginResponse {
    final String accessToken;
    final String tokenType;
    final String idToken;
    final String expiration;
    final bool? passwordResetRequired;
    final String? message;

    LoginResponse({
        required this.accessToken,
        required this.tokenType,
        required this.idToken,
        required this.expiration,
        this.passwordResetRequired,
        this.message,
    });

    factory LoginResponse.fromJson(Map<String, dynamic> json) {
        return LoginResponse(
            accessToken: json['access_token'],
            tokenType: json['token_type'],
            idToken: json['id_token'],
            expiration: json['expiration'],
            passwordResetRequired: json['password_reset_required'],
            message: json['message'],
        );
    }
}

Future<LoginResponse> login(String email, String password) async {
    final response = await http.post(
        Uri.parse('https://fdplay-api.infraifd.com/api/v1/token'),
        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
        body: 'username=$email&password=$password',
    );

    if (response.statusCode != 200) {
        final error = jsonDecode(response.body);
        throw Exception(error['detail'] ?? 'Login failed');
    }

    final data = LoginResponse.fromJson(jsonDecode(response.body));

    const storage = FlutterSecureStorage();
    await storage.write(key: 'access_token', value: data.accessToken);
    await storage.write(key: 'token_type', value: data.tokenType);
    await storage.write(key: 'id_token', value: data.idToken);

    final expiresAt = DateTime.parse(data.expiration).millisecondsSinceEpoch;
    await storage.write(key: 'token_expires_at', value: expiresAt.toString());

    return data;
}

Requisicoes Autenticadas

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

const _storage = FlutterSecureStorage();

Future<http.Response> authenticatedFetch(
    String url, {
    String method = 'GET',
    Map<String, String>? extraHeaders,
    Object? body,
}) async {
    final token = await _storage.read(key: 'access_token');

    if (token == null) {
        throw Exception('No access token found. Please login.');
    }

    final headers = {
        'Authorization': 'Bearer $token',
        'Content-Type': 'application/json',
        ...?extraHeaders,
    };

    final uri = Uri.parse(url);
    late http.Response response;

    switch (method) {
        case 'GET':
            response = await http.get(uri, headers: headers);
        case 'POST':
            response = await http.post(uri, headers: headers, body: body);
        case 'PUT':
            response = await http.put(uri, headers: headers, body: body);
        case 'DELETE':
            response = await http.delete(uri, headers: headers);
        default:
            throw Exception('Unsupported HTTP method: $method');
    }

    if (response.statusCode == 401) {
        final refreshed = await refreshToken();
        if (!refreshed) {
            throw Exception('Session expired. Please login again.');
        }
        return authenticatedFetch(url, method: method, extraHeaders: extraHeaders, body: body);
    }

    return response;
}

Future<Map<String, dynamic>> fetchVideos() async {
    final response = await authenticatedFetch(
        'https://fdplay-api.infraifd.com/api/v1/videos?query={"enable":true}',
    );
    return jsonDecode(response.body);
}

Auto-Refresh de Token

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

const _storage = FlutterSecureStorage();

Future<bool> refreshToken() async {
    final expiresAtStr = await _storage.read(key: 'token_expires_at');
    final expiresAt = int.tryParse(expiresAtStr ?? '0') ?? 0;

    // Se token expira em menos de 5 minutos, redirecionar ao login
    if (DateTime.now().millisecondsSinceEpoch > expiresAt - 5 * 60 * 1000) {
        // NOTA: Esta API nao implementa endpoint de refresh.
        // Quando o token expirar, o usuario deve fazer login novamente.
        return false;
    }

    return true;
}

Logout

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

const _storage = FlutterSecureStorage();

Future<void> logout() async {
    await _storage.delete(key: 'access_token');
    await _storage.delete(key: 'token_type');
    await _storage.delete(key: 'id_token');
    await _storage.delete(key: 'token_expires_at');

    // Navegar para tela de login (usar Navigator ou GoRouter no contexto do app)
}

Flutter Provider para Autenticacao

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class AuthProvider extends ChangeNotifier {
    static const _storage = FlutterSecureStorage();
    static const String _baseUrl = 'https://fdplay-api.infraifd.com/api/v1';

    bool _isAuthenticated = false;
    bool _loading = true;

    bool get isAuthenticated => _isAuthenticated;
    bool get loading => _loading;

    AuthProvider() {
        _checkStoredToken();
    }

    Future<void> _checkStoredToken() async {
        final token = await _storage.read(key: 'access_token');
        final expiresAtStr = await _storage.read(key: 'token_expires_at');
        final expiresAt = int.tryParse(expiresAtStr ?? '0') ?? 0;

        if (token != null && DateTime.now().millisecondsSinceEpoch < expiresAt) {
            _isAuthenticated = true;
        }

        _loading = false;
        notifyListeners();
    }

    Future<void> login(String email, String password) async {
        final response = await http.post(
            Uri.parse('$_baseUrl/token'),
            headers: {'Content-Type': 'application/x-www-form-urlencoded'},
            body: 'username=$email&password=$password',
        );

        if (response.statusCode != 200) {
            final error = jsonDecode(response.body);
            throw Exception(error['detail'] ?? 'Login failed');
        }

        final data = jsonDecode(response.body);
        await _storage.write(key: 'access_token', value: data['access_token']);
        await _storage.write(key: 'token_type', value: data['token_type']);
        await _storage.write(key: 'id_token', value: data['id_token']);

        final expiresAt = DateTime.parse(data['expiration']).millisecondsSinceEpoch;
        await _storage.write(key: 'token_expires_at', value: expiresAt.toString());

        _isAuthenticated = true;
        notifyListeners();
    }

    Future<void> logout() async {
        await _storage.delete(key: 'access_token');
        await _storage.delete(key: 'token_type');
        await _storage.delete(key: 'id_token');
        await _storage.delete(key: 'token_expires_at');

        _isAuthenticated = false;
        notifyListeners();
    }
}

Uso no app (com provider package):

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
    runApp(
        ChangeNotifierProvider(
            create: (_) => AuthProvider(),
            child: const MyApp(),
        ),
    );
}

class ProtectedPage extends StatelessWidget {
    const ProtectedPage({super.key});

    @override
    Widget build(BuildContext context) {
        final auth = context.watch<AuthProvider>();

        if (auth.loading) {
            return const Center(child: CircularProgressIndicator());
        }

        if (!auth.isAuthenticated) {
            return const LoginPage();
        }

        return Scaffold(
            appBar: AppBar(
                title: const Text('Dashboard'),
                actions: [
                    IconButton(
                        icon: const Icon(Icons.logout),
                        onPressed: () => auth.logout(),
                    ),
                ],
            ),
            body: const Center(child: Text('Conteudo protegido')),
        );
    }
}

⚠️ Segurança

Boas Práticas

  1. NUNCA armazene tokens em locais inseguros:

    // ❌ EVITAR (dados sensiveis em SharedPreferences sem criptografia)
    final prefs = await SharedPreferences.getInstance();
    prefs.setString('access_token', accessToken);
    
    // ✅ USAR FlutterSecureStorage (criptografado)
    const storage = FlutterSecureStorage();
    await storage.write(key: 'access_token', value: accessToken);
    

  2. SEMPRE use HTTPS em produção:

    ✅ https://fdplay-api.infraifd.com/api/v1/token
    ❌ http://fdplay-api.infraifd.com/api/v1/token (inseguro)
    

  3. Validar expiracao de token no frontend:

    final expiresAtStr = await storage.read(key: 'token_expires_at');
    final expiresAt = int.tryParse(expiresAtStr ?? '0') ?? 0;
    if (DateTime.now().millisecondsSinceEpoch > expiresAt) {
     // Token expirado, fazer logout
     await logout();
    }
    

  4. Limpar tokens ao fazer logout:

    const storage = FlutterSecureStorage();
    await storage.delete(key: 'access_token');
    await storage.delete(key: 'token_type');
    await storage.delete(key: 'id_token');
    await storage.delete(key: 'token_expires_at');
    

  5. Nao logar tokens no console:

    // ❌ EVITAR
    debugPrint('Token: $accessToken');
    
    // ✅ USAR (em desenvolvimento)
    debugPrint('User authenticated: $isAuthenticated');
    


🔍 Troubleshooting

Erro 401: Could not validate credentials

Causas: - Token JWT expirado - Token malformado ou inválido - Secret key incorreto no servidor - Header Authorization ausente ou malformado

Soluções: 1. Verificar formato do header: Authorization: Bearer <token> 2. Verificar expiração do token (claim exp) 3. Fazer login novamente 4. Verificar que SECRET_KEY no servidor é consistente


Erro 401: Incorrect email or password

Causas: - Email ou senha incorretos - Usuário não existe no banco de dados - Senha não corresponde ao hash Argon2

Soluções: 1. Verificar credenciais digitadas 2. Resetar senha via POST /api/v1/auth/forgot-password — veja Recuperação de Senha 3. Verificar que usuário existe: GET /api/v1/users?query={"email":"user@example.com"}


Erro 403: User account is inactive

Causas: - Campo enable=false no documento do usuário - Conta desativada por admin

Soluções: 1. Reativar conta via admin dashboard 2. Atualizar campo: PUT /api/v1/users {"_id": "...", "enable": true}


Token Expirando Muito Rápido

Configurar tempo de expiração:

# application/infrastructure/common/config/settings.py
ACCESS_TOKEN_EXPIRE_MINUTES = 60  # 1 hora (padrão)

# Para alterar:
ACCESS_TOKEN_EXPIRE_MINUTES = 1440  # 24 horas

Archive Token — Acesso Público a Arquivos

O archive_token é um token de sessão gerado no login que permite acessar arquivos do GridFS (thumbnails, imagens, documentos) sem necessidade de JWT.

Por que existe?

Em tags HTML como <img src="..."> e <video src="...">, não é possível enviar o header Authorization. O archive_token resolve isso permitindo acesso via URL direta.

Como funciona

┌─────────┐     POST /token          ┌─────────┐
│ Frontend │ ─────────────────────── │   API   │
│          │ ◄── access_token        │         │
│          │ ◄── archive_token       │         │
└─────────┘                          └─────────┘
     │  Para exibir uma imagem/arquivo:
  <img src="/api/v1/archive-records-public/{archive_token}?f={file_id}" />
  1. Frontend faz login → recebe access_token (JWT) + archive_token
  2. Para exibir arquivos, usa a URL pública com o archive_token e o file_id como query param f
  3. O token expira junto com o JWT (12h por padrão) e é renovado a cada login

Endpoint

GET /api/v1/archive-records-public/{archive_token}?f={file_id}
Parâmetro Tipo Local Descrição
archive_token string path Token de sessão recebido no login
f string query ObjectId do arquivo no GridFS

Response (200): Streaming do arquivo com Content-Type detectado automaticamente (ex: image/webp, image/png, application/pdf).

Renovar archive_token

Se o frontend restaurar a sessão localmente e o archive_token estiver ausente ou expirado, use o endpoint autenticado abaixo para rotacionar apenas o token de arquivos, sem exigir novo login.

POST /api/v1/auth/refresh-archive-token
Authorization: Bearer {access_token}

Response (200 OK):

{
  "archive_token": "tqbxvElUHs2e14rQOo-AOd6NXJ7Nk_Ttb3xdwEOzxCk",
  "archive_token_expires_at": "2026-03-28T15:30:00+00:00",
  "expires_in_minutes": 60
}
Campo Tipo Descrição
archive_token string Novo token de sessão para acesso público a arquivos
archive_token_expires_at string (ISO 8601 UTC) Data/hora de expiração do novo token
expires_in_minutes integer TTL configurado para o token

Exemplo Flutter/Dart

// Após o login, armazenar o archive_token
final loginResponse = await http.post(
  Uri.parse('$baseUrl/api/v1/token'),
  headers: {'Content-Type': 'application/x-www-form-urlencoded'},
  body: 'username=user@example.com&password=Senha123!',
);

final data = jsonDecode(loginResponse.body);
final accessToken = data['access_token'];    // JWT para endpoints autenticados
final archiveToken = data['archive_token'];  // Token para arquivos públicos

// Construir URL para exibir thumbnail
String buildFileUrl(String fileId) {
  return '$baseUrl/api/v1/archive-records-public/$archiveToken?f=$fileId';
}

// Usar em Image.network ou qualquer widget
Image.network(
  buildFileUrl('69bea6244a2410cd09d8c210'),
  fit: BoxFit.cover,
);

Exemplo HTML/JavaScript

// Após login
const { access_token, archive_token } = await loginResponse.json();

// Construir URL de arquivo
const fileUrl = `/api/v1/archive-records-public/${archive_token}?f=${fileId}`;

// Usar diretamente em tags HTML
document.querySelector('img').src = fileUrl;
document.querySelector('video source').src = fileUrl;

Respostas de Erro

Status Código Descrição
403 FORBIDDEN Token inválido (Invalid archive token.)
403 FORBIDDEN Token expirado (Archive token expired.)
400 BAD_REQUEST file_id inválido (não é um ObjectId válido)
404 NOT_FOUND Arquivo não encontrado no GridFS
// Token inválido
{
  "error": {
    "code": "FORBIDDEN",
    "message": "Invalid archive token.",
    "timestamp": "2026-03-21T18:35:34Z"
  }
}

Boas Práticas

  • Armazene o archive_token junto com o JWT (localStorage, SharedPreferences, etc.)
  • Renove ambos no login — o archive_token é regenerado a cada login
  • Não use o archive_token como substituto do JWT — ele serve exclusivamente para acesso a arquivos
  • O token é único por usuário — um novo login invalida o token anterior

Compatibilidade com QR Codes

A rota /archive-records-public/{token} continua aceitando tokens Fernet (gerados pelo endpoint de QR code no admin). As duas formas coexistem:

  • QR Code (admin): /archive-records-public/{fernet_encrypted_file_id} — token auto-contido com file_id encriptado
  • Sessão (login): /archive-records-public/{archive_token}?f={file_id} — token de sessão + file_id separado

Downloads Autenticados via URL (?token=)

Endpoints que exigem JWT mas retornam arquivos binários (imagens, XLSX, ZIP, PNG) suportam o JWT também como query param ?token=, como alternativa ao header Authorization: Bearer.

Isso é necessário quando não é possível enviar headers HTTP — por exemplo: Image.network(), launchUrl(), WebView, tags <img>, window.open().

Endpoints com suporte a ?token=

Endpoint Retorna Uso típico
GET /archive-records/{file_id}?token= Arquivo (inline) Visualizar arquivo autenticado
GET /qrcode/{text}?token= PNG Gerar QR code de texto
GET /qrcode/{file_id}/{is_public}?token= PNG Gerar QR code de arquivo
GET /admin/events-dashboard/events/export?token= XLSX / ZIP / JSON (?format=) Download do relatório de eventos (ADR-059)
GET /admin/finance/export?token= XLSX / ZIP / JSON (?format=) Download do relatório financeiro (ADR-059)
GET /admin/promo-usage/export?token= XLSX / CSV / JSON (?format=) Download de uso de cupons (ADR-059)
GET /admin/customers/export?token= XLSX / CSV / JSON (?format=) Download de clientes segmentados (ADR-059)

Nota (ADR-059): todos os 4 endpoints /export têm ?format=xlsx (default), ?format=csv (single-aba: CSV; multi-aba: ZIP) e ?format=json (estrutura aninhada {meta, sheets}).

Como usar

GET /api/v1/archive-records/{file_id}?token=<access_token>
GET /api/v1/qrcode/{text}?token=<access_token>
GET /api/v1/admin/events-dashboard/events/export?token=<access_token>&format=xlsx
GET /api/v1/admin/finance/export?token=<access_token>&format=csv

O access_token é o mesmo JWT retornado no login (access_token do response de POST /api/v1/token).

Exemplo Flutter/Dart

// Download de relatório no formato escolhido (xlsx default)
Future<void> downloadReport(
  String token, {
  String format = 'xlsx', // 'xlsx' | 'csv' | 'json'
}) async {
  final uri = Uri.parse(
    '$baseUrl/api/v1/admin/events-dashboard/events/export'
    '?token=$token&format=$format',
  );
  await launchUrl(uri, mode: LaunchMode.externalApplication);
}

// QR code como Image.network (sem header Authorization)
Widget buildQrCode(String text, String token) {
  return Image.network(
    '$baseUrl/api/v1/qrcode/${Uri.encodeComponent(text)}?token=$token',
  );
}

// Arquivo autenticado em Image.network
Widget buildAuthenticatedFile(String fileId, String token) {
  return Image.network(
    '$baseUrl/api/v1/archive-records/$fileId?token=$token',
  );
}

Comparativo: archive_token vs ?token=

archive_token ?token=<jwt>
Para Thumbnails, imagens públicas do conteúdo Arquivos admin, QR codes, relatórios
Endpoint /archive-records-public/ /archive-records/, /qrcode/, exports
Auth Token de sessão (não JWT) JWT completo
Expiração Junto com o JWT (12h) Junto com o JWT
Acesso Qualquer arquivo do GridFS Exige user autenticado

Segurança

O JWT no query param fica visível em logs de servidor e histórico de browser. Use apenas em contextos onde Authorization header é inviável (downloads, Image.network, launchUrl). Para chamadas normais de API, sempre prefira o header Authorization: Bearer <token>.


🚀 Próximos Passos

  1. Refresh tokens persistentes - Tokens de longa duração para renovação
  2. OAuth2 social login - Login com Google, Facebook, etc.
  3. Two-Factor Authentication (2FA) - Segurança adicional com TOTP
  4. ~~Password reset flow - Recuperação de senha via email~~ ✅ Implementado — veja Forgot Password e Reset Password
  5. Session management - Invalidar tokens ativos (logout forçado)
  6. ~~Email verification - Verificacao de email no cadastro~~ ✅ Implementado — veja Resend Verification e Guia Frontend
  7. Rate limiting - Prevenir ataques de força bruta no login