feat: implement email verification and password reset features
- Add EmailTokenService for handling email verification and password reset tokens. - Create EmailService for sending verification and reset emails via SMTP. - Update AuthService to handle email verification status during login. - Modify user registration to redirect to a check email screen instead of issuing a token. - Implement resend verification email functionality. - Add deep link handling for email verification and password reset in the mobile app. - Update mobile app routes and components to support new email verification flow. - Enhance error handling for unverified emails during login attempts. - Update configuration to include SMTP settings for email service.
This commit is contained in:
Generated
+141
@@ -8,6 +8,18 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -56,6 +68,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
@@ -433,6 +454,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chumsky"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"stacker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
@@ -672,6 +703,22 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -754,6 +801,12 @@ dependencies = [
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.6"
|
||||
@@ -992,6 +1045,16 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1522,6 +1585,34 @@ version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chumsky",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"httpdate",
|
||||
"idna 1.1.0",
|
||||
"mime",
|
||||
"nom",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls 0.23.37",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"url",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
@@ -1685,6 +1776,7 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"image",
|
||||
"jsonwebtoken",
|
||||
"lettre",
|
||||
"oauth2",
|
||||
"printpdf",
|
||||
"qrcode",
|
||||
@@ -1884,6 +1976,15 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.37.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2151,6 +2252,16 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.28"
|
||||
@@ -2245,6 +2356,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
@@ -2601,6 +2718,7 @@ version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -3091,6 +3209,20 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
@@ -3931,6 +4063,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
|
||||
@@ -65,6 +65,9 @@ base64 = "0.22"
|
||||
# PDF generation
|
||||
printpdf = "0.7"
|
||||
|
||||
# Email (SMTP)
|
||||
lettre = { version = "0.11", features = ["smtp-transport", "tokio1-rustls-tls", "builder"], default-features = false }
|
||||
|
||||
# Async trait
|
||||
async-trait = "0.1"
|
||||
|
||||
|
||||
+172
-5
@@ -23,6 +23,7 @@ Backend da aplicação MeowSpool escrito em **Rust**, utilizando **Axum** como f
|
||||
| Logging | `tracing`, `tracing-subscriber` | 0.1 |
|
||||
| Validação | `validator` | 0.18 |
|
||||
| HTTP Client | `reqwest` (json, rustls-tls) | 0.12 |
|
||||
| Email (SMTP) | `lettre` (smtp-transport, tokio1-rustls-tls, builder) | 0.11 |
|
||||
| Geração QR Code | `qrcode` | 0.14 |
|
||||
| Geração de imagem| `image` | 0.25 |
|
||||
| Encode Base64 | `base64` | 0.22 |
|
||||
@@ -41,7 +42,8 @@ backend/
|
||||
├── migrations/ <- SQL puro, gerenciado pelo SQLx CLI
|
||||
│ ├── 20240101000001_create_users.sql
|
||||
│ ├── 20240101000002_create_spool_presets.sql
|
||||
│ └── 20240101000003_create_filaments.sql
|
||||
│ ├── 20240101000003_create_filaments.sql
|
||||
│ └── 20240101000004_create_token_tables.sql <- password_reset_tokens + email_verification_tokens
|
||||
└── src/
|
||||
├── main.rs <- entry point: inicializa config, DB, router e servidor
|
||||
├── config.rs <- struct Config lida de variáveis de ambiente
|
||||
@@ -63,9 +65,14 @@ backend/
|
||||
├── application/ <- CASOS DE USO: orquestram domínio + ports
|
||||
│ ├── mod.rs
|
||||
│ ├── auth_service.rs <- login, register, OAuth, refresh, logout
|
||||
│ ├── email_token_service.rs <- forgot_password, verify_email, reset_password (tokens DB + email)
|
||||
│ ├── filament_service.rs <- CRUD, cálculo de peso líquido, QR, SVG
|
||||
│ └── spool_preset_service.rs <- CRUD presets (system read-only, user CRUD)
|
||||
│
|
||||
├── infrastructure/ <- Serviços externos (SMTP, etc.)
|
||||
│ ├── mod.rs
|
||||
│ └── email_service.rs <- EmailService: SMTP via lettre (STARTTLS)
|
||||
│
|
||||
└── adapters/
|
||||
├── inbound/ <- HTTP: recebe requisições, delega ao application
|
||||
│ ├── mod.rs
|
||||
@@ -119,9 +126,19 @@ Todas as rotas são prefixadas com `/api/v1`.
|
||||
| POST | `/oauth/google` | `google_oauth_handler` | Público |
|
||||
| POST | `/refresh` | `refresh_token_handler` | Público (requer refresh token) |
|
||||
| POST | `/logout` | `logout_handler` | Autenticado |
|
||||
| POST | `/forgot-password` | `forgot_password_handler` | Público |
|
||||
| POST | `/verify-email` | `verify_email_handler` | Público |
|
||||
| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset) |
|
||||
| POST | `/resend-verification` | `resend_verification_handler` | Público (sempre 200) |
|
||||
| POST | `/forgot-password` | `forgot_password_handler` | Público |
|
||||
| POST | `/verify-email` | `verify_email_handler` | Público |
|
||||
| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset) |
|
||||
|
||||
### Redirects para Deep Links — `/api/v1`
|
||||
|
||||
| Método | Rota | Handler | Descrição |
|
||||
| ------ | ----------------- | --------------------------------- | ---------------------------------------------------------- |
|
||||
| GET | `/verify-email` | `verify_email_redirect_handler` | Redireciona 302 → `{APP_SCHEME}://verify-email?token=xxx` |
|
||||
| GET | `/reset-password` | `reset_password_redirect_handler` | Redireciona 302 → `{APP_SCHEME}://reset-password?token=xxx`|
|
||||
|
||||
> Esses endpoints são os **destinos dos links nos e-mails**. Clientes de e-mail (Gmail etc.) aceitam URLs `https://` normalmente; o backend redireciona para o deep link do app. O OS reconhece o scheme e abre o MeowSpool.
|
||||
|
||||
### Users — `/api/v1/users`
|
||||
|
||||
@@ -204,9 +221,15 @@ pub enum AppError {
|
||||
#[error("validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String), // token inválido, expirado, já utilizado
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("unprocessable entity: {0}")]
|
||||
UnprocessableEntity(String),
|
||||
|
||||
#[error("internal error")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
}
|
||||
@@ -311,7 +334,26 @@ Arquivos ficam em `backend/migrations/`. Nomeie com timestamp e descrição clar
|
||||
|
||||
## Variáveis de Ambiente
|
||||
|
||||
Copie `.env.example` para `.env` antes de rodar. Veja o arquivo `.env.example` para a lista completa.
|
||||
Copie `.env.example` para `.env` antes de rodar. Variáveis principais:
|
||||
|
||||
| Variável | Obrigatória | Padrão | Descrição |
|
||||
| ------------------------- | ----------- | ----------------------- | ---------------------------------------------- |
|
||||
| `DATABASE_URL` | ✅ | — | Connection string PostgreSQL |
|
||||
| `JWT_SECRET` | ✅ | — | Segredo de assinatura JWT |
|
||||
| `JWT_EXPIRY_SECS` | ❌ | `3600` | TTL do access token (segundos) |
|
||||
| `JWT_REFRESH_EXPIRY_SECS` | ❌ | `2592000` | TTL do refresh token (segundos) |
|
||||
| `GOOGLE_CLIENT_ID` | ❌ | — | Client ID OAuth Google |
|
||||
| `GOOGLE_CLIENT_SECRET` | ❌ | — | Client Secret OAuth Google |
|
||||
| `HOST` | ❌ | `0.0.0.0` | Endereço de bind do servidor |
|
||||
| `PORT` | ❌ | `8080` | Porta do servidor |
|
||||
| `APP_ENV` | ❌ | `development` | `development` ou `production` |
|
||||
| `SMTP_HOST` | ❌ | `smtp.gmail.com` | Servidor SMTP |
|
||||
| `SMTP_PORT` | ❌ | `587` | Porta SMTP (STARTTLS) |
|
||||
| `SMTP_USER` | ❌ | — | Usuário SMTP (e-mail) |
|
||||
| `SMTP_PASS` | ❌ | — | Senha / App Password SMTP |
|
||||
| `EMAIL_FROM` | ❌ | `noreply@meowspool.app` | Endereço remetente dos e-mails |
|
||||
| `APP_BASE_URL` | ❌ | `http://localhost:8080` | URL base usada nos links de e-mail. Em produção: `https://meowspool.felipecncloud.com/api/v1` |
|
||||
| `APP_SCHEME` | ❌ | `meowspool` | Scheme do deep link do app mobile. Usado nos redirects de e-mail |
|
||||
|
||||
---
|
||||
|
||||
@@ -447,6 +489,131 @@ GET /api/v1/filaments/:id/label.svg?width_mm=22&height_mm=14&fields=color,name,q
|
||||
|
||||
## Mudanças Recentes (14/03/2026)
|
||||
|
||||
### ✅ Reenvio de e-mail de verificação
|
||||
|
||||
**Arquivos modificados**: `src/application/email_token_service.rs`, `src/adapters/inbound/auth_handler.rs`, `src/router.rs`
|
||||
|
||||
**Novo endpoint**: `POST /api/v1/auth/resend-verification` — body: `{ "email": "..." }`
|
||||
- Sempre retorna `200 OK` (não vaza se e-mail existe ou já foi verificado)
|
||||
- Ignora silenciosamente se: e-mail não cadastrado, conta já verificada
|
||||
- Reutiliza `send_verification_email` internamente (gera novo token com TTL 24h)
|
||||
|
||||
**Novo método** (`email_token_service.rs`):
|
||||
```rust
|
||||
pub async fn resend_verification_email(&self, email: &str) -> Result<(), AppError> {
|
||||
let Some(user) = self.user_repo.find_by_email(email).await? else { return Ok(()); };
|
||||
if user.email_verified { return Ok(()); }
|
||||
self.send_verification_email(user.id, email).await
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ✅ Registro não emite tokens — login bloqueado sem verificação de e-mail
|
||||
|
||||
**Arquivos modificados**: `src/application/auth_service.rs`, `src/adapters/inbound/auth_handler.rs`, `src/error.rs`
|
||||
|
||||
**Problema**: após o registro, o app autenticava o usuário imediatamente (emitia tokens) sem exigir verificação de e-mail. Login também aceitava contas não verificadas.
|
||||
|
||||
**Mudanças**:
|
||||
|
||||
- `AppError::EmailNotVerified` adicionado → HTTP 403, code `"EMAIL_NOT_VERIFIED"`
|
||||
- `AuthService::register` passa a retornar apenas `User` (sem `TokenPair`) — o handler responde `201` sem body de auth
|
||||
- `AuthService::login` verifica `user.email_verified` antes de emitir tokens:
|
||||
```rust
|
||||
if !user.email_verified {
|
||||
return Err(AppError::EmailNotVerified);
|
||||
}
|
||||
```
|
||||
|
||||
**Fluxo resultante**:
|
||||
1. Registro → `201 Created` (sem tokens) → backend envia e-mail de verificação em background
|
||||
2. Login com e-mail não verificado → `403 { "code": "EMAIL_NOT_VERIFIED" }`
|
||||
3. Após verificar e-mail → login funciona normalmente
|
||||
|
||||
---
|
||||
|
||||
### ✅ Redirect HTTP → Deep Link para links de e-mail
|
||||
|
||||
**Arquivos modificados**: `src/adapters/inbound/auth_handler.rs`, `src/router.rs`, `src/config.rs`, `.env`
|
||||
|
||||
**Problema**: Clientes de e-mail (Gmail, etc.) bloqueiam links com scheme customizado (`meowspool://`). O link no e-mail não abria o app.
|
||||
|
||||
**Solução**: O backend agora gera links `https://` nos e-mails. Ao clicar, o backend redireciona (302) para o deep link do app.
|
||||
|
||||
**Fluxo completo**:
|
||||
```
|
||||
E-mail → https://meowspool.felipecncloud.com/api/v1/verify-email?token=xxx
|
||||
↓ GET (browser abre normalmente)
|
||||
Backend responde 302 Location: meowspool://verify-email?token=xxx
|
||||
↓ OS reconhece o scheme
|
||||
App MeowSpool abre → app/verify-email.tsx
|
||||
↓
|
||||
POST /auth/verify-email { token } → verifica no banco
|
||||
```
|
||||
|
||||
**Novos handlers** (`auth_handler.rs`):
|
||||
- `verify_email_redirect_handler` — `GET /api/v1/verify-email?token=xxx`
|
||||
- `reset_password_redirect_handler` — `GET /api/v1/reset-password?token=xxx`
|
||||
|
||||
**Novo campo Config** (`config.rs`):
|
||||
- `app_scheme: String` — lido de `APP_SCHEME` (padrão: `meowspool`)
|
||||
|
||||
**`.env` atualizado**:
|
||||
- `APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1` (antes: `meowspool:/`)
|
||||
- `APP_SCHEME=meowspool` (novo)
|
||||
|
||||
---
|
||||
|
||||
### ✅ Fluxo completo de e-mail: verificação e reset de senha
|
||||
|
||||
**Arquivos criados/modificados**: `migrations/20240101000004_create_token_tables.sql`, `src/infrastructure/email_service.rs`, `src/infrastructure/mod.rs`, `src/application/email_token_service.rs`, `src/adapters/inbound/auth_handler.rs`, `src/config.rs`, `src/error.rs`, `src/main.rs`, `src/router.rs`, `Cargo.toml`
|
||||
|
||||
#### Migrations
|
||||
|
||||
Criadas as tabelas `password_reset_tokens` e `email_verification_tokens` com:
|
||||
- UUID como PK (gen_random_uuid)
|
||||
- `token TEXT UNIQUE` — indexado para lookup rápido
|
||||
- `used_at TIMESTAMPTZ` — `NULL` = não usado; preenchido na validação para invalidar após uso
|
||||
- `expires_at TIMESTAMPTZ` — TTL: 1h para reset de senha, 24h para verificação de e-mail
|
||||
|
||||
#### EmailService (`src/infrastructure/email_service.rs`)
|
||||
|
||||
Envia e-mails via SMTP com STARTTLS usando `lettre`. Métodos:
|
||||
- `send_password_reset(to, reset_link)` — e-mail de redefinição de senha
|
||||
- `send_email_verification(to, verify_link)` — e-mail de confirmação de conta
|
||||
|
||||
#### EmailTokenService (`src/application/email_token_service.rs`)
|
||||
|
||||
Orquestra tokens no banco + envio de e-mail. Métodos:
|
||||
- `forgot_password(email)` — cria token em `password_reset_tokens`, envia e-mail. Sempre retorna `Ok` (não vaza se o e-mail existe)
|
||||
- `verify_email(token)` — valida token em `email_verification_tokens`, chama `user_repo.verify_email()`, marca token como usado
|
||||
- `reset_password(token, new_password)` — valida token em `password_reset_tokens`, re-hash da senha com Argon2id, atualiza usuário, marca token como usado
|
||||
- `send_verification_email(user_id, email)` — cria token em `email_verification_tokens` e envia e-mail. Chamado após o registro
|
||||
|
||||
#### Handlers implementados
|
||||
|
||||
Anteriormente retornavam `200 OK` sem lógica. Agora delegam ao `EmailTokenService`:
|
||||
- `forgot_password_handler` — POST `/auth/forgot-password`
|
||||
- `verify_email_handler` — POST `/auth/verify-email`
|
||||
- `reset_password_handler` — POST `/auth/reset-password`
|
||||
|
||||
#### Registro envia e-mail de verificação
|
||||
|
||||
`register_handler` chama `email_token_service.send_verification_email()` via `tokio::spawn` (background) após criar o usuário — o cadastro responde imediatamente sem esperar o SMTP.
|
||||
|
||||
#### AppError::BadRequest adicionado
|
||||
|
||||
Nova variante para erros previsíveis do usuário (token inválido, expirado, já usado) → HTTP 400.
|
||||
|
||||
#### Configuração `APP_BASE_URL`
|
||||
|
||||
- Em produção: `APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1`
|
||||
- Gera links nos e-mails como `https://meowspool.felipecncloud.com/api/v1/verify-email?token=xxx`
|
||||
- O backend redireciona esse GET para o deep link via `APP_SCHEME`
|
||||
|
||||
---
|
||||
|
||||
### ✅ Suporte a Impressoras Pequenas — Niimbot e layout adaptativo
|
||||
|
||||
**Arquivos alterados**: `filament_handler.rs`, `filament_service.rs`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Forgot Password
|
||||
type: http
|
||||
seq: 5
|
||||
seq: 6
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -14,6 +14,13 @@ http:
|
||||
"password": "12345678"
|
||||
}
|
||||
|
||||
script:
|
||||
res: |
|
||||
if (res.status === 200) {
|
||||
bru.setEnvVar("access_token", res.getBody().access_token);
|
||||
bru.setEnvVar("refresh_token", res.getBody().refresh_token);
|
||||
}
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
info:
|
||||
name: Logout
|
||||
type: http
|
||||
seq: 2
|
||||
seq: 5
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: "{{base_url}}/api/v1/auth/logout"
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"email": "felipe@felipecncloud.com",
|
||||
"password": "l11f06c10"
|
||||
}
|
||||
auth:
|
||||
type: bearer
|
||||
token: "{{access_token}}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: OAuth Google
|
||||
type: http
|
||||
seq: 8
|
||||
seq: 3
|
||||
|
||||
http:
|
||||
method: POST
|
||||
@@ -13,6 +13,13 @@ http:
|
||||
"id_token": "{{google_id_token}}"
|
||||
}
|
||||
|
||||
script:
|
||||
res: |
|
||||
if (res.status === 200) {
|
||||
bru.setEnvVar("access_token", res.getBody().access_token);
|
||||
bru.setEnvVar("refresh_token", res.getBody().refresh_token);
|
||||
}
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
|
||||
@@ -13,6 +13,13 @@ http:
|
||||
"refresh_token": "{{refresh_token}}"
|
||||
}
|
||||
|
||||
script:
|
||||
res: |
|
||||
if (res.status === 200) {
|
||||
bru.setEnvVar("access_token", res.getBody().access_token);
|
||||
bru.setEnvVar("refresh_token", res.getBody().refresh_token);
|
||||
}
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
|
||||
@@ -10,7 +10,7 @@ http:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"email": "felipe@felipecncloud.com",
|
||||
"email": "felipecaninn18@gmail.com",
|
||||
"password": "12345678"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Reset Password
|
||||
type: http
|
||||
seq: 7
|
||||
seq: 8
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Verify Email
|
||||
type: http
|
||||
seq: 6
|
||||
seq: 7
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -10,16 +10,16 @@ http:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"material": "PLA",
|
||||
"brand": "Bambu",
|
||||
"model": "PLA Basic",
|
||||
"material": "PETG",
|
||||
"brand": "Elegoo",
|
||||
"model": "Rapid",
|
||||
"color_hex": "#FF5733",
|
||||
"spool_preset_id": "{{preset_id}}",
|
||||
"total_weight_g": 1250,
|
||||
"temp_hotend_c": 220,
|
||||
"temp_bed_c": 60,
|
||||
"flow_factor_pct": 1.0,
|
||||
"notes": "Filamento de teste"
|
||||
"total_weight_g": 527,
|
||||
"temp_hotend_c": 250,
|
||||
"temp_bed_c": 70,
|
||||
"flow_factor_pct": 0.0033,
|
||||
"notes": "Filamento"
|
||||
}
|
||||
auth:
|
||||
type: bearer
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
info:
|
||||
name: Delete Me
|
||||
type: http
|
||||
seq: 3
|
||||
|
||||
http:
|
||||
method: DELETE
|
||||
url: "{{base_url}}/api/v1/users/me"
|
||||
auth:
|
||||
type: bearer
|
||||
token: "{{access_token}}"
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
@@ -10,3 +10,9 @@ variables:
|
||||
value: 07188f3b-2b74-4251-88bc-037de7628d65
|
||||
- name: filament_id
|
||||
value: ""
|
||||
- name: google_id_token
|
||||
value: ""
|
||||
- name: email_verify_token
|
||||
value: 0351fde6cc2546499c2910fe109528fc
|
||||
- name: reset_token
|
||||
value: ""
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE password_reset_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX ON password_reset_tokens (token);
|
||||
|
||||
CREATE TABLE email_verification_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX ON email_verification_tokens (token);
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use axum::{extract::{Query, State}, http::{header, StatusCode}, response::IntoResponse, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
|
||||
@@ -40,11 +40,22 @@ pub struct ForgotPasswordRequest {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct ResendVerificationRequest {
|
||||
#[validate(email)]
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VerifyEmailRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TokenQueryParams {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct ResetPasswordRequest {
|
||||
pub token: String,
|
||||
@@ -68,15 +79,18 @@ pub async fn register_handler(
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
req.validate()?;
|
||||
let tokens = state.auth_service.register(req.email, req.password).await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(AuthResponse {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
))
|
||||
let email = req.email.clone();
|
||||
let user = state.auth_service.register(req.email, req.password).await?;
|
||||
|
||||
// Envia email de verificação em background para não bloquear a resposta
|
||||
let ets = state.email_token_service.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = ets.send_verification_email(user.id, &email).await {
|
||||
tracing::warn!(error = %e, "Failed to send verification email");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
pub async fn login_handler(
|
||||
@@ -124,30 +138,66 @@ pub async fn logout_handler() -> impl IntoResponse {
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
pub async fn resend_verification_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ResendVerificationRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
req.validate()?;
|
||||
state.email_token_service.resend_verification_email(&req.email).await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
pub async fn forgot_password_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ForgotPasswordRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
req.validate()?;
|
||||
// TODO: gerar token de reset e enviar email
|
||||
// Retornamos sempre 200 para não vazar informação sobre emails cadastrados
|
||||
// Sempre retorna 200 para não vazar informação sobre emails cadastrados
|
||||
state.email_token_service.forgot_password(&req.email).await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
pub async fn verify_email_handler(
|
||||
Json(_req): Json<VerifyEmailRequest>,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<VerifyEmailRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// TODO: validar token e marcar email_verified = true
|
||||
state.email_token_service.verify_email(&req.token).await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
pub async fn reset_password_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ResetPasswordRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
req.validate()?;
|
||||
// TODO: validar token de reset e atualizar senha
|
||||
state.email_token_service.reset_password(&req.token, &req.new_password).await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// GET /verify-email?token=xxx → redireciona para o deep link do app
|
||||
pub async fn verify_email_redirect_handler(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<TokenQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let location = format!(
|
||||
"{}://verify-email?token={}",
|
||||
state.config.app_scheme, params.token
|
||||
);
|
||||
(StatusCode::FOUND, [(header::LOCATION, location)])
|
||||
}
|
||||
|
||||
/// GET /reset-password?token=xxx → redireciona para o deep link do app
|
||||
pub async fn reset_password_redirect_handler(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<TokenQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let location = format!(
|
||||
"{}://reset-password?token={}",
|
||||
state.config.app_scheme, params.token
|
||||
);
|
||||
(StatusCode::FOUND, [(header::LOCATION, location)])
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helper: verificação de Google ID Token
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::{extract::State, response::IntoResponse, Extension, Json};
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Extension, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
@@ -68,5 +68,18 @@ pub async fn update_me_handler(
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
req.validate()?;
|
||||
// TODO: implementar atualização de perfil
|
||||
Ok(axum::http::StatusCode::OK)
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
pub async fn delete_me_handler(
|
||||
State(state): State<AppState>,
|
||||
Extension(current_user): Extension<CurrentUser>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
state
|
||||
.auth_service
|
||||
.user_repo_ref()
|
||||
.delete(current_user.id)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -100,6 +100,15 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
|
||||
sqlx::query("DELETE FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.db.as_ref())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -60,16 +60,14 @@ impl AuthService {
|
||||
// Registro com email/senha
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pub async fn register(&self, email: String, password: String) -> Result<TokenPair, AppError> {
|
||||
if let Some(_) = self.user_repo.find_by_email(&email).await? {
|
||||
pub async fn register(&self, email: String, password: String) -> Result<User, AppError> {
|
||||
if self.user_repo.find_by_email(&email).await?.is_some() {
|
||||
return Err(AppError::Conflict("email already registered".into()));
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&password)?;
|
||||
let user = User::new_with_password(email, password_hash);
|
||||
let user = self.user_repo.create(&user).await?;
|
||||
|
||||
self.issue_token_pair(&user)
|
||||
let new_user = User::new_with_password(email, password_hash);
|
||||
Ok(self.user_repo.create(&new_user).await?)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -86,6 +84,10 @@ impl AuthService {
|
||||
let hash = user.password_hash.as_deref().ok_or(AppError::Unauthorized)?;
|
||||
verify_password(&password, hash)?;
|
||||
|
||||
if !user.email_verified {
|
||||
return Err(AppError::EmailNotVerified);
|
||||
}
|
||||
|
||||
self.issue_token_pair(&user)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
application::auth_service::hash_password,
|
||||
config::Config,
|
||||
error::AppError,
|
||||
infrastructure::EmailService,
|
||||
ports::UserRepository,
|
||||
};
|
||||
|
||||
pub struct EmailTokenService {
|
||||
db: Arc<PgPool>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
email_svc: Arc<EmailService>,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl EmailTokenService {
|
||||
pub fn new(
|
||||
db: Arc<PgPool>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
email_svc: Arc<EmailService>,
|
||||
config: Config,
|
||||
) -> Self {
|
||||
Self { db, user_repo, email_svc, config }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Forgot password: gera token e envia email
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pub async fn forgot_password(&self, email: &str) -> Result<(), AppError> {
|
||||
// Não revelamos se o email existe ou não
|
||||
let Some(user) = self.user_repo.find_by_email(email).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let token = generate_token();
|
||||
let expires_at = time::OffsetDateTime::now_utc() + time::Duration::hours(1);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(&token)
|
||||
.bind(expires_at)
|
||||
.execute(self.db.as_ref())
|
||||
.await?;
|
||||
|
||||
let reset_link = format!(
|
||||
"{}/reset-password?token={}",
|
||||
self.config.app_base_url, token
|
||||
);
|
||||
self.email_svc.send_password_reset(email, &reset_link).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Verify email: valida token e marca email_verified = true
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pub async fn verify_email(&self, token: &str) -> Result<(), AppError> {
|
||||
let row = sqlx::query_as::<_, TokenRow>(
|
||||
"SELECT id, user_id, used_at, expires_at FROM email_verification_tokens WHERE token = $1",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(self.db.as_ref())
|
||||
.await?
|
||||
.ok_or(AppError::BadRequest("Token inválido".into()))?;
|
||||
|
||||
validate_token(&row)?;
|
||||
|
||||
self.user_repo.verify_email(row.user_id).await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(row.id)
|
||||
.execute(self.db.as_ref())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reset password: valida token e atualiza senha
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pub async fn reset_password(&self, token: &str, new_password: &str) -> Result<(), AppError> {
|
||||
let row = sqlx::query_as::<_, TokenRow>(
|
||||
"SELECT id, user_id, used_at, expires_at FROM password_reset_tokens WHERE token = $1",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(self.db.as_ref())
|
||||
.await?
|
||||
.ok_or(AppError::BadRequest("Token inválido".into()))?;
|
||||
|
||||
validate_token(&row)?;
|
||||
|
||||
let mut user = self
|
||||
.user_repo
|
||||
.find_by_id(row.user_id)
|
||||
.await?
|
||||
.ok_or(AppError::BadRequest("Usuário não encontrado".into()))?;
|
||||
|
||||
user.password_hash = Some(hash_password(new_password)?);
|
||||
self.user_repo.update(&user).await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(row.id)
|
||||
.execute(self.db.as_ref())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Geração de token de verificação de email (chamado no registro)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reenvio de e-mail de verificação (endpoint público)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pub async fn resend_verification_email(&self, email: &str) -> Result<(), AppError> {
|
||||
// Não revela se o e-mail existe ou se já foi verificado
|
||||
let Some(user) = self.user_repo.find_by_email(email).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
if user.email_verified {
|
||||
return Ok(());
|
||||
}
|
||||
self.send_verification_email(user.id, email).await
|
||||
}
|
||||
|
||||
pub async fn send_verification_email(&self, user_id: Uuid, email: &str) -> Result<(), AppError> {
|
||||
let token = generate_token();
|
||||
let expires_at = time::OffsetDateTime::now_utc() + time::Duration::hours(24);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&token)
|
||||
.bind(expires_at)
|
||||
.execute(self.db.as_ref())
|
||||
.await?;
|
||||
|
||||
let verify_link = format!(
|
||||
"{}/verify-email?token={}",
|
||||
self.config.app_base_url, token
|
||||
);
|
||||
self.email_svc.send_email_verification(email, &verify_link).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
fn generate_token() -> String {
|
||||
Uuid::new_v4().to_string().replace('-', "")
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TokenRow {
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
used_at: Option<time::OffsetDateTime>,
|
||||
expires_at: time::OffsetDateTime,
|
||||
}
|
||||
|
||||
fn validate_token(row: &TokenRow) -> Result<(), AppError> {
|
||||
if row.used_at.is_some() {
|
||||
return Err(AppError::BadRequest("Token já utilizado".into()));
|
||||
}
|
||||
if time::OffsetDateTime::now_utc() > row.expires_at {
|
||||
return Err(AppError::BadRequest("Token expirado".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod auth_service;
|
||||
pub mod email_token_service;
|
||||
pub mod filament_service;
|
||||
pub mod spool_preset_service;
|
||||
|
||||
pub use auth_service::AuthService;
|
||||
pub use email_token_service::EmailTokenService;
|
||||
pub use filament_service::FilamentService;
|
||||
pub use spool_preset_service::SpoolPresetService;
|
||||
|
||||
@@ -12,6 +12,14 @@ pub struct Config {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub app_env: AppEnv,
|
||||
// Email (SMTP)
|
||||
pub smtp_host: String,
|
||||
pub smtp_port: u16,
|
||||
pub smtp_user: String,
|
||||
pub smtp_pass: String,
|
||||
pub email_from: String,
|
||||
pub app_base_url: String,
|
||||
pub app_scheme: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -32,6 +40,13 @@ impl Config {
|
||||
host: optional_string_default("HOST", "0.0.0.0"),
|
||||
port: optional("PORT", 8080)?,
|
||||
app_env: parse_app_env(),
|
||||
smtp_host: optional_string_default("SMTP_HOST", "smtp.gmail.com"),
|
||||
smtp_port: optional("SMTP_PORT", 587)?,
|
||||
smtp_user: optional_string("SMTP_USER"),
|
||||
smtp_pass: optional_string("SMTP_PASS"),
|
||||
email_from: optional_string_default("EMAIL_FROM", "noreply@meowspool.app"),
|
||||
app_base_url: optional_string_default("APP_BASE_URL", "http://localhost:8080"),
|
||||
app_scheme: optional_string_default("APP_SCHEME", "meowspool"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,15 @@ pub enum AppError {
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("email not verified")]
|
||||
EmailNotVerified,
|
||||
|
||||
#[error("validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
@@ -37,7 +43,9 @@ impl AppError {
|
||||
Self::NotFound => (StatusCode::NOT_FOUND, "NOT_FOUND"),
|
||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED"),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, "FORBIDDEN"),
|
||||
Self::EmailNotVerified => (StatusCode::FORBIDDEN, "EMAIL_NOT_VERIFIED"),
|
||||
Self::Validation(_) => (StatusCode::BAD_REQUEST, "VALIDATION_ERROR"),
|
||||
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "BAD_REQUEST"),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, "CONFLICT"),
|
||||
Self::UnprocessableEntity(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UNPROCESSABLE_ENTITY"),
|
||||
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR"),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
use lettre::{
|
||||
message::header::ContentType,
|
||||
transport::smtp::authentication::Credentials,
|
||||
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||
};
|
||||
|
||||
use crate::{config::Config, error::AppError};
|
||||
|
||||
pub struct EmailService {
|
||||
mailer: AsyncSmtpTransport<Tokio1Executor>,
|
||||
from: String,
|
||||
}
|
||||
|
||||
impl EmailService {
|
||||
pub fn new(config: &Config) -> Result<Self, AppError> {
|
||||
let creds = Credentials::new(config.smtp_user.clone(), config.smtp_pass.clone());
|
||||
|
||||
let mailer = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp_host)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("SMTP config error: {e}")))?
|
||||
.port(config.smtp_port)
|
||||
.credentials(creds)
|
||||
.build();
|
||||
|
||||
Ok(Self {
|
||||
mailer,
|
||||
from: config.email_from.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn send_password_reset(&self, to: &str, reset_link: &str) -> Result<(), AppError> {
|
||||
let body = format!(
|
||||
"Você solicitou a redefinição de senha do MeowSpool.\n\nClique no link abaixo para redefinir sua senha (válido por 1 hora):\n\n{reset_link}\n\nSe você não solicitou isso, ignore este email."
|
||||
);
|
||||
|
||||
self.send(to, "Redefinição de senha — MeowSpool", &body).await
|
||||
}
|
||||
|
||||
pub async fn send_email_verification(&self, to: &str, verify_link: &str) -> Result<(), AppError> {
|
||||
let body = format!(
|
||||
"Bem-vindo ao MeowSpool! Confirme seu endereço de email clicando no link abaixo:\n\n{verify_link}\n\nO link expira em 24 horas."
|
||||
);
|
||||
|
||||
self.send(to, "Confirme seu email — MeowSpool", &body).await
|
||||
}
|
||||
|
||||
async fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), AppError> {
|
||||
let email = Message::builder()
|
||||
.from(
|
||||
self.from
|
||||
.parse()
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Invalid from address: {e}")))?,
|
||||
)
|
||||
.to(to
|
||||
.parse()
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Invalid to address: {e}")))?)
|
||||
.subject(subject)
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(body.to_string())
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Email build error: {e}")))?;
|
||||
|
||||
AsyncTransport::send(&self.mailer, email)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("SMTP send error: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod email_service;
|
||||
|
||||
pub use email_service::EmailService;
|
||||
@@ -8,6 +8,7 @@ mod application;
|
||||
mod config;
|
||||
mod domain;
|
||||
mod error;
|
||||
mod infrastructure;
|
||||
mod ports;
|
||||
mod router;
|
||||
|
||||
|
||||
@@ -13,4 +13,5 @@ pub trait UserRepository: Send + Sync {
|
||||
async fn create(&self, user: &User) -> Result<User, AppError>;
|
||||
async fn update(&self, user: &User) -> Result<User, AppError>;
|
||||
async fn verify_email(&self, id: Uuid) -> Result<(), AppError>;
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
|
||||
}
|
||||
|
||||
+19
-2
@@ -18,8 +18,9 @@ use crate::{
|
||||
PostgresUserRepository,
|
||||
},
|
||||
},
|
||||
application::{AuthService, FilamentService, SpoolPresetService},
|
||||
application::{AuthService, EmailTokenService, FilamentService, SpoolPresetService},
|
||||
config::Config,
|
||||
infrastructure::EmailService,
|
||||
ports::{FilamentRepository, SpoolPresetRepository, UserRepository},
|
||||
};
|
||||
|
||||
@@ -27,6 +28,7 @@ use crate::{
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub auth_service: Arc<AuthService>,
|
||||
pub email_token_service: Arc<EmailTokenService>,
|
||||
pub filament_service: Arc<FilamentService>,
|
||||
pub spool_preset_service: Arc<SpoolPresetService>,
|
||||
pub config: Config,
|
||||
@@ -46,6 +48,15 @@ pub fn build(db: PgPool, config: Config) -> Router {
|
||||
Arc::clone(&user_repo),
|
||||
config.clone(),
|
||||
));
|
||||
let email_svc = Arc::new(
|
||||
EmailService::new(&config).expect("Failed to configure SMTP"),
|
||||
);
|
||||
let email_token_service = Arc::new(EmailTokenService::new(
|
||||
Arc::clone(&db),
|
||||
Arc::clone(&user_repo),
|
||||
Arc::clone(&email_svc),
|
||||
config.clone(),
|
||||
));
|
||||
let filament_service = Arc::new(FilamentService::new(
|
||||
Arc::clone(&filament_repo),
|
||||
Arc::clone(&preset_repo),
|
||||
@@ -54,6 +65,7 @@ pub fn build(db: PgPool, config: Config) -> Router {
|
||||
|
||||
let state = AppState {
|
||||
auth_service,
|
||||
email_token_service,
|
||||
filament_service,
|
||||
spool_preset_service,
|
||||
config: config.clone(),
|
||||
@@ -65,9 +77,13 @@ pub fn build(db: PgPool, config: Config) -> Router {
|
||||
.route("/auth/login", post(auth_handler::login_handler))
|
||||
.route("/auth/oauth/google", post(auth_handler::google_oauth_handler))
|
||||
.route("/auth/refresh", post(auth_handler::refresh_token_handler))
|
||||
.route("/auth/resend-verification", post(auth_handler::resend_verification_handler))
|
||||
.route("/auth/forgot-password", post(auth_handler::forgot_password_handler))
|
||||
.route("/auth/verify-email", post(auth_handler::verify_email_handler))
|
||||
.route("/auth/reset-password", post(auth_handler::reset_password_handler));
|
||||
.route("/auth/reset-password", post(auth_handler::reset_password_handler))
|
||||
// Redirects para deep links (usados nos links de email)
|
||||
.route("/verify-email", get(auth_handler::verify_email_redirect_handler))
|
||||
.route("/reset-password", get(auth_handler::reset_password_redirect_handler));
|
||||
|
||||
// Rotas protegidas (requerem JWT válido)
|
||||
let protected_routes = Router::new()
|
||||
@@ -75,6 +91,7 @@ pub fn build(db: PgPool, config: Config) -> Router {
|
||||
// Users
|
||||
.route("/users/me", get(user_handler::get_me_handler))
|
||||
.route("/users/me", put(user_handler::update_me_handler))
|
||||
.route("/users/me", delete(user_handler::delete_me_handler))
|
||||
// Dashboard
|
||||
.route("/dashboard", get(filament_handler::dashboard_handler))
|
||||
// Filaments
|
||||
|
||||
Reference in New Issue
Block a user