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:
Request Body (Form Data):
Aceita tanto
username=nascwanequantousername=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:
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/subscriptionestá 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
- 401
PASSWORD_RESET_REQUIRED→ Redirecionar para forgot-password (legado sem senha) - 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):
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:
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/meouPUT /customers/me). Também limpa automaticamente a flagpassword_reset_requiredpara usuários migrados do sistema legado.
Headers:
Request Body:
| 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):
Response (400 Bad Request - Senha Atual Incorreta):
Response (404 Not Found - Usuário Não Encontrado):
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:
Request Body:
| 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:
Response (429 Too Many Requests — Cooldown de Reenvio):
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 campopassword_reset_code_hashdo 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:
Request Body:
| 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):
Response (400 Bad Request — Código Expirado):
Response (400 Bad Request — Código Inválido):
Response (429 Too Many Requests — Tentativas Esgotadas):
Response (404 Not Found — Usuário Não Encontrado):
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:
Request Body:
| 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):
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_tokenatual expirou ou esta ausente aposrestoreSession().
Headers:
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):
🔑 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:
- Usar rota com bypass (
/customers/me/subscribeao invés de rota admin) - Associar usuário a um perfil em
users_type - 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¶
-
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); -
SEMPRE use HTTPS em produção:
-
Validar expiracao de token no frontend:
-
Limpar tokens ao fazer logout:
-
Nao logar tokens no console:
🔍 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}" />
- Frontend faz login → recebe
access_token(JWT) +archive_token - Para exibir arquivos, usa a URL pública com o
archive_tokene ofile_idcomo query paramf - O token expira junto com o JWT (12h por padrão) e é renovado a cada login
Endpoint¶
| 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.
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_tokenjunto com o JWT (localStorage, SharedPreferences, etc.) - Renove ambos no login — o
archive_tokené regenerado a cada login - Não use o
archive_tokencomo 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
/exporttê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¶
- Refresh tokens persistentes - Tokens de longa duração para renovação
- OAuth2 social login - Login com Google, Facebook, etc.
- Two-Factor Authentication (2FA) - Segurança adicional com TOTP
- ~~Password reset flow - Recuperação de senha via email~~ ✅ Implementado — veja Forgot Password e Reset Password
- Session management - Invalidar tokens ativos (logout forçado)
- ~~Email verification - Verificacao de email no cadastro~~ ✅ Implementado — veja Resend Verification e Guia Frontend
- Rate limiting - Prevenir ataques de força bruta no login