From c57f851a8b51ea9ea967c902a39d91a5c621d131 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 9 Oct 2026 21:08:33 +0200 Subject: [PATCH] feat(accounts): outgoing mail over SMTP (Resend), email verification, fail-closed password reset - mail/ module (lettre, ru/en templates), links built only from PUBLIC_BASE_URL - migration 0006: accounts.email_verified_at, shared email_tokens table (verify + reset), existing accounts marked verified - reset mailer no longer logs tokens; RESET_MAIL_MODE=log is dev-only and refused with COOKIE_SECURE=true; Disabled by default answers 503 - forgot-password and resend-verification do their work in a background task (no timing oracle) - device linking requires a verified email; email_verified exposed via /me and gRPC - gateway rate limits, SMTP_* in compose and .env.example - frontend: verify, forgot-password, reset-password pages, verify banner, ru/en strings --- backend/.env.example | 17 ++ backend/Cargo.lock | 54 ++++ backend/accounts-service/Cargo.toml | 1 + .../migrations/0006_email_tokens.sql | 33 +++ .../accounts-service/src/accounts/handlers.rs | 4 + .../accounts-service/src/accounts/model.rs | 21 ++ backend/accounts-service/src/accounts/repo.rs | 9 +- backend/accounts-service/src/auth/handlers.rs | 30 +- backend/accounts-service/src/auth/mod.rs | 1 + .../src/auth/reset/handlers.rs | 40 ++- .../accounts-service/src/auth/reset/mod.rs | 257 +++++++++++++----- backend/accounts-service/src/auth/tokens.rs | 75 +++++ .../src/auth/verify/handlers.rs | 95 +++++++ .../accounts-service/src/auth/verify/mod.rs | 50 ++++ backend/accounts-service/src/config.rs | 83 +++++- .../accounts-service/src/device/handlers.rs | 10 + backend/accounts-service/src/device/links.rs | 16 +- backend/accounts-service/src/error.rs | 10 + backend/accounts-service/src/grpc/mod.rs | 3 +- backend/accounts-service/src/lib.rs | 16 +- backend/accounts-service/src/mail/mod.rs | 177 ++++++++++++ .../accounts-service/src/mail/templates.rs | 170 ++++++++++++ backend/accounts-service/tests/common/mod.rs | 23 +- backend/accounts-service/tests/device_flow.rs | 2 + .../tests/device_flow/links.rs | 13 +- .../accounts-service/tests/email_verify.rs | 212 +++++++++++++++ .../accounts-service/tests/password_reset.rs | 47 +++- backend/addons-registry/README.md | 15 + backend/common/proto/accounts.proto | 8 +- backend/docker-compose.prod.yml | 11 + backend/gateway/src/rate_limit/rules.rs | 16 ++ frontend/src/app/routes.tsx | 3 + .../account/tests/avatarUpload.test.tsx | 1 + .../src/features/auth/VerifyEmailBanner.tsx | 48 ++++ frontend/src/features/auth/api.ts | 16 ++ frontend/src/features/auth/i18n/en.ts | 29 ++ frontend/src/features/auth/i18n/ru.ts | 29 ++ .../src/features/auth/tests/forms.test.tsx | 2 +- .../src/features/auth/tests/pages.test.tsx | 2 +- .../src/features/auth/tests/session.test.tsx | 2 +- .../src/features/auth/tests/verify.test.tsx | 81 ++++++ .../pages/public/auth/ForgotPasswordPage.tsx | 63 +++++ .../pages/public/auth/ResetPasswordPage.tsx | 73 +++++ frontend/src/pages/public/auth/VerifyPage.tsx | 48 ++++ frontend/src/shared/api/client.ts | 7 +- frontend/src/shared/layout/AppShell.tsx | 2 + .../src/shared/layout/tests/layout.test.tsx | 3 +- frontend/src/shared/types.ts | 2 + 48 files changed, 1810 insertions(+), 120 deletions(-) create mode 100644 backend/accounts-service/migrations/0006_email_tokens.sql create mode 100644 backend/accounts-service/src/auth/verify/handlers.rs create mode 100644 backend/accounts-service/src/auth/verify/mod.rs create mode 100644 backend/accounts-service/src/mail/mod.rs create mode 100644 backend/accounts-service/src/mail/templates.rs create mode 100644 backend/accounts-service/tests/email_verify.rs create mode 100644 frontend/src/features/auth/VerifyEmailBanner.tsx create mode 100644 frontend/src/features/auth/tests/verify.test.tsx create mode 100644 frontend/src/pages/public/auth/ForgotPasswordPage.tsx create mode 100644 frontend/src/pages/public/auth/ResetPasswordPage.tsx create mode 100644 frontend/src/pages/public/auth/VerifyPage.tsx diff --git a/backend/.env.example b/backend/.env.example index d2d4925b..e3fc44b3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,3 +32,20 @@ DOWNLOADS_DIR=downloads # configs-service CONFIGS_DATABASE_URL=postgres://lovisual:lovisual@localhost:5432/configs_db CONFIGS_PORT=8082 +# --- outgoing mail (accounts-service) --- +# Empty SMTP_HOST = mail switched off: /auth/forgot-password answers 503 and +# verification emails are skipped (fail-closed). Resend: host smtp.resend.com, +# user "resend", password = API key. 587 = STARTTLS (default), 465 = implicit TLS. +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +# Sender mailbox shown to recipients, e.g. LoVisual +MAIL_FROM= +# The only origin email links may carry, e.g. https://visual.loki-code.dev +PUBLIC_BASE_URL= +# Email template language: ru (default) or en +MAIL_LANG=ru +# Development-only logging mailer (prints NO tokens): RESET_MAIL_MODE=log. +# Refused at startup when COOKIE_SECURE=true; tests use the queue instead. +RESET_MAIL_MODE= diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 9844329e..37fa7ba2 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -20,6 +20,7 @@ dependencies = [ "dotenvy", "hex", "image", + "lettre", "rand 0.10.3", "serde", "serde_json", @@ -1291,6 +1292,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + [[package]] name = "email_address" version = "0.2.9" @@ -2274,6 +2285,33 @@ dependencies = [ "spin 0.9.9", ] +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls 0.23.45", + "socket2 0.6.5", + "tokio", + "tokio-rustls 0.26.5", + "url", + "webpki-roots", +] + [[package]] name = "libc" version = "0.2.189" @@ -2470,6 +2508,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nonzero_ext" version = "0.3.0" @@ -2947,6 +2994,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -3229,6 +3282,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", diff --git a/backend/accounts-service/Cargo.toml b/backend/accounts-service/Cargo.toml index 50b72fd8..e961ee87 100644 --- a/backend/accounts-service/Cargo.toml +++ b/backend/accounts-service/Cargo.toml @@ -33,6 +33,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", anyhow = "1" dotenvy = "0.15" tonic = "0.14" +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls"] } [dev-dependencies] axum-test = "21" diff --git a/backend/accounts-service/migrations/0006_email_tokens.sql b/backend/accounts-service/migrations/0006_email_tokens.sql new file mode 100644 index 00000000..7997b446 --- /dev/null +++ b/backend/accounts-service/migrations/0006_email_tokens.sql @@ -0,0 +1,33 @@ +-- Email verification + shared single-use tokens. +-- +-- One table for both token purposes (email_verify / password_reset): the +-- semantics are identical (hashed opaque token, short TTL, single use, +-- issuing a new one supersedes the pending one), so 0005's dedicated +-- password_reset_tokens table is folded into it. Pending reset links (30 +-- minute TTL) are carried over instead of silently invalidated. +-- +-- Existing accounts are marked verified at migration time: they signed up +-- before verification existed, and locking them out of device linking would +-- break every linked mod on deploy. + +ALTER TABLE accounts ADD COLUMN email_verified_at TIMESTAMPTZ; + +UPDATE accounts SET email_verified_at = now(); + +CREATE TABLE email_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + purpose TEXT NOT NULL CHECK (purpose IN ('email_verify', 'password_reset')), + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_email_tokens_account_id ON email_tokens(account_id); + +INSERT INTO email_tokens (account_id, purpose, token_hash, expires_at, used_at, created_at) +SELECT account_id, 'password_reset', token_hash, expires_at, used_at, created_at +FROM password_reset_tokens; + +DROP TABLE password_reset_tokens; diff --git a/backend/accounts-service/src/accounts/handlers.rs b/backend/accounts-service/src/accounts/handlers.rs index 2e917350..099071f8 100644 --- a/backend/accounts-service/src/accounts/handlers.rs +++ b/backend/accounts-service/src/accounts/handlers.rs @@ -22,6 +22,9 @@ pub struct MeResponse { pub display_nick: String, pub role: String, pub avatar_url: Option, + /// Drives the site's "confirm your email" banner and the mod's + /// verification prompt. + pub email_verified: bool, pub created_at: DateTime, } @@ -41,6 +44,7 @@ pub async fn me( display_nick: account.display_nick, role: account.role, avatar_url, + email_verified: account.email_verified_at.is_some(), created_at: account.created_at, })) } diff --git a/backend/accounts-service/src/accounts/model.rs b/backend/accounts-service/src/accounts/model.rs index bd038c1b..603157ef 100644 --- a/backend/accounts-service/src/accounts/model.rs +++ b/backend/accounts-service/src/accounts/model.rs @@ -13,6 +13,9 @@ pub struct Account { pub display_nick: String, pub role: String, pub can_publish_addons: bool, + /// `None` until the address is confirmed via an `email_verify` token. + #[serde(skip_serializing)] + pub email_verified_at: Option>, pub created_at: DateTime, } @@ -34,6 +37,9 @@ pub fn validate_register( /// that accepts one (register, password reset request). The lookup itself is /// case-insensitive: `accounts_email_lower_idx` is a unique index on /// `lower(email)`, and `repo::find_by_email` lowercases the query value. +/// Control characters are rejected outright: the address ends up in SMTP +/// headers and mail templates, and a stray newline there is an injection, +/// not an inconvenience. pub fn normalize_email(email: &str) -> Result { let email = email.trim(); if email.is_empty() { @@ -44,6 +50,11 @@ pub fn normalize_email(email: &str) -> Result { "email must be at most 254 characters".into(), )); } + if email.chars().any(|c| c.is_control()) { + return Err(AppError::Validation( + "email must not contain control characters".into(), + )); + } let mut parts = email.split('@'); let (Some(local), Some(domain)) = (parts.next(), parts.next()) else { return Err(AppError::Validation("email must contain '@'".into())); @@ -134,4 +145,14 @@ mod tests { let (email, password, _) = valid(); assert!(validate_register(&email, &password, &"a".repeat(33)).is_err()); } + + #[test] + fn control_characters_in_email_are_rejected() { + // A newline inside the address would be an SMTP/log injection, not + // an odd-looking address. + assert!(normalize_email("a\nb@example.com").is_err()); + assert!(normalize_email("a\tb@example.com").is_err()); + assert!(normalize_email("a\u{0}b@example.com").is_err()); + assert!(normalize_email("clean@example.com").is_ok()); + } } diff --git a/backend/accounts-service/src/accounts/repo.rs b/backend/accounts-service/src/accounts/repo.rs index fd2db91e..adb4e8dd 100644 --- a/backend/accounts-service/src/accounts/repo.rs +++ b/backend/accounts-service/src/accounts/repo.rs @@ -17,7 +17,8 @@ pub async fn create( sqlx::query_as::<_, Account>( "INSERT INTO accounts (email, password_hash, display_nick) VALUES ($1, $2, $3) - RETURNING id, email, password_hash, display_nick, role, can_publish_addons, created_at", + RETURNING id, email, password_hash, display_nick, role, can_publish_addons, + email_verified_at, created_at", ) .bind(normalize_email(email)) .bind(password_hash) @@ -28,7 +29,8 @@ pub async fn create( pub async fn find_by_email(pool: &PgPool, email: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, Account>( - "SELECT id, email, password_hash, display_nick, role, can_publish_addons, created_at + "SELECT id, email, password_hash, display_nick, role, can_publish_addons, + email_verified_at, created_at FROM accounts WHERE lower(email) = $1", ) .bind(normalize_email(email)) @@ -38,7 +40,8 @@ pub async fn find_by_email(pool: &PgPool, email: &str) -> Result pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, Account>( - "SELECT id, email, password_hash, display_nick, role, can_publish_addons, created_at + "SELECT id, email, password_hash, display_nick, role, can_publish_addons, + email_verified_at, created_at FROM accounts WHERE id = $1", ) .bind(id) diff --git a/backend/accounts-service/src/auth/handlers.rs b/backend/accounts-service/src/auth/handlers.rs index f2d09bed..459030e3 100644 --- a/backend/accounts-service/src/auth/handlers.rs +++ b/backend/accounts-service/src/auth/handlers.rs @@ -1,7 +1,8 @@ use crate::accounts::model::validate_register; use crate::accounts::repo; -use crate::auth::{password, reset, tokens}; +use crate::auth::{password, reset, tokens, verify}; use crate::error::{AppError, AppJson}; +use crate::mail; use axum::{Json, extract::State, http::StatusCode}; use axum_extra::extract::cookie::CookieJar; use common::jwt; @@ -13,8 +14,8 @@ pub struct AuthState { pub jwt_secret: String, pub cookie_secure: bool, pub hasher: password::PasswordHasher, - /// Reset-email delivery back-end: Log in production (until a real - /// provider lands), Queue in tests. + /// Outgoing-mail back-end: `Disabled` unless configured (fail-closed), + /// `Log` only in dev, SMTP in production, `Queue` in tests. pub mail: reset::MailBox, // A real Argon2id hash of a throwaway string. `login` verifies against // it when the email is unknown so that "no such account" costs the same @@ -25,7 +26,7 @@ pub struct AuthState { impl AuthState { pub fn new(pool: sqlx::PgPool, jwt_secret: String, cookie_secure: bool) -> Self { - Self::with_mail(pool, jwt_secret, cookie_secure, reset::MailBox::Log) + Self::with_mail(pool, jwt_secret, cookie_secure, reset::MailBox::Disabled) } pub fn with_mail( @@ -74,6 +75,27 @@ pub async fn register( .await .map_err(AppError::Internal)?; let account = repo::create(&state.pool, &email, &hash, &nick).await?; + // The verification mail goes out in the background: registration must + // not wait on SMTP, and a delivery failure is only a log line (the user + // can resend from the site). Logged without the address or the token. + if state.mail.enabled() { + let (mail, pool, account_id, email) = ( + state.mail.clone(), + state.pool.clone(), + account.id, + account.email.clone(), + ); + tokio::spawn(async move { + if let Err(err) = + verify::handlers::send_verification_email(&pool, &mail, account_id).await + { + tracing::error!( + address_tag = %mail::address_tag(&email), + "verification email task failed: {err:#}" + ); + } + }); + } Ok(( StatusCode::CREATED, Json(RegisterResponse { diff --git a/backend/accounts-service/src/auth/mod.rs b/backend/accounts-service/src/auth/mod.rs index d34e9676..c61420aa 100644 --- a/backend/accounts-service/src/auth/mod.rs +++ b/backend/accounts-service/src/auth/mod.rs @@ -2,3 +2,4 @@ pub mod handlers; pub mod password; pub mod reset; pub mod tokens; +pub mod verify; diff --git a/backend/accounts-service/src/auth/reset/handlers.rs b/backend/accounts-service/src/auth/reset/handlers.rs index d4c62ed0..a5600f8a 100644 --- a/backend/accounts-service/src/auth/reset/handlers.rs +++ b/backend/accounts-service/src/auth/reset/handlers.rs @@ -1,8 +1,10 @@ use crate::accounts::{model, repo}; -use crate::auth::reset; +use crate::auth::reset::{self, MailBox}; use crate::error::{AppError, AppJson}; +use crate::mail; use axum::{Json, extract::State, http::StatusCode}; use serde::{Deserialize, Serialize}; +use sqlx::PgPool; use super::super::handlers::AuthState; #[derive(Deserialize)] @@ -18,18 +20,31 @@ pub struct AcceptedResponse { /// POST /auth/forgot-password { email } /// /// The response is identical whether or not the email is registered: no -/// oracle for account enumeration. Delivery happens out of band; the gateway -/// rate-limits this route per IP (3/hour) to keep the mailer from being -/// weaponised. +/// oracle for account enumeration. The account lookup, token issuance and +/// delivery all run in a background task, and the handler answers 202 before +/// any database access, so response timing carries no trace of account +/// existence either. Delivery errors are logged with an address tag only — +/// never the email, never the token. +/// +/// Fail-closed: when no mail back-end is configured the endpoint answers the +/// same 503 for every address. pub async fn forgot_password( State(state): State, AppJson(req): AppJson, ) -> Result<(StatusCode, Json), AppError> { let email = model::normalize_email(&req.email)?; - if let Some(account) = repo::find_by_email(&state.pool, &email).await? { - let token = reset::issue_reset_token(&state.pool, account.id).await?; - state.mail.send(&email, &token); + if !state.mail.enabled() { + return Err(AppError::Unavailable); } + let (mail, pool) = (state.mail.clone(), state.pool.clone()); + tokio::spawn(async move { + if let Err(err) = forgot_task(&pool, &mail, &email).await { + tracing::error!( + address_tag = %mail::address_tag(&email), + "forgot-password background task failed: {err:#}" + ); + } + }); Ok(( StatusCode::ACCEPTED, Json(AcceptedResponse { @@ -38,6 +53,17 @@ pub async fn forgot_password( )) } +/// The off-request-path half of `forgot_password`. An unknown address is a +/// silent no-op: the caller already got the same 202 as everyone else. +async fn forgot_task(pool: &PgPool, mail: &MailBox, email: &str) -> anyhow::Result<()> { + let Some(account) = repo::find_by_email(pool, email).await? else { + return Ok(()); + }; + let token = reset::issue_reset_token(pool, account.id).await?; + mail.send_reset(email, &token).await; + Ok(()) +} + #[derive(Deserialize)] pub struct ResetPasswordRequest { pub token: String, diff --git a/backend/accounts-service/src/auth/reset/mod.rs b/backend/accounts-service/src/auth/reset/mod.rs index c241b88f..560b0211 100644 --- a/backend/accounts-service/src/auth/reset/mod.rs +++ b/backend/accounts-service/src/auth/reset/mod.rs @@ -1,51 +1,13 @@ pub mod handlers; +use std::sync::Arc; + use sqlx::PgPool; use tokio::sync::mpsc::UnboundedSender; use uuid::Uuid; -use crate::auth::tokens::{hash_token, new_opaque_token}; - -/// One outgoing reset email. `token` is the plaintext token: the only place -/// it ever exists outside the response of `issue_reset_token`. -#[derive(Debug, Clone)] -pub struct Mail { - pub to: String, - pub token: String, -} - -/// Delivery back-end for reset emails. -#[derive(Clone)] -pub enum MailBox { - /// Development delivery: a structured log line carrying the token, which - /// local/dev stacks pick up from the container logs. When a real provider - /// is wired in (lettre/SES/anything), this variant is the single switch - /// point - the endpoint contract does not change. - Log, - /// In-process queue: tests (and a future in-process mail worker) receive - /// every mail exactly as the handler produced it. - Queue(UnboundedSender), -} - -impl MailBox { - pub fn send(&self, to: &str, token: &str) { - match self { - MailBox::Log => { - tracing::info!( - account = %to, - reset_token = %token, - "password reset requested; deliver the reset link to the account owner" - ); - } - MailBox::Queue(tx) => { - let _ = tx.send(Mail { - to: to.to_string(), - token: token.to_string(), - }); - } - } - } -} +use crate::auth::tokens::{EmailTokenPurpose, consume_email_token, issue_email_token}; +use crate::mail::{self, Lang, Mailer, SmtpMailer, templates}; /// Reset links must be used quickly: long windows turn a leaked email into /// an account takeover. 30 minutes is the common industry compromise. @@ -60,43 +22,22 @@ pub fn is_well_formed_token(token: &str) -> bool { token.len() == TOKEN_PREFIX.len() + 43 && token.starts_with(TOKEN_PREFIX) } -/// Invalidates any token still pending for the account, then stores the hash -/// of a fresh one. Returns the plaintext token for the mailer only — it is -/// never persisted in clear form. +/// Invalidates any pending reset token of the account, then stores the hash +/// of a fresh one (atomically, see `issue_email_token`). pub async fn issue_reset_token(pool: &PgPool, account_id: Uuid) -> Result { - sqlx::query( - "UPDATE password_reset_tokens SET used_at = now() - WHERE account_id = $1 AND used_at IS NULL", + issue_email_token( + pool, + account_id, + EmailTokenPurpose::PasswordReset, + TOKEN_PREFIX, + TTL_MINUTES, ) - .bind(account_id) - .execute(pool) - .await?; - let token = new_opaque_token(TOKEN_PREFIX); - sqlx::query( - "INSERT INTO password_reset_tokens (account_id, token_hash, expires_at) - VALUES ($1, $2, now() + make_interval(mins => $3::int))", - ) - .bind(account_id) - .bind(hash_token(&token)) - .bind(TTL_MINUTES) - .execute(pool) - .await?; - Ok(token) + .await } -/// Atomically marks the token as used and returns its account. The single -/// conditional UPDATE makes double-spend impossible even for concurrent -/// callers: exactly one of them gets the row back. +/// Atomically marks the token as used and returns its account. pub async fn consume_reset_token(pool: &PgPool, token: &str) -> Result, sqlx::Error> { - let row: Option<(Uuid,)> = sqlx::query_as( - "UPDATE password_reset_tokens SET used_at = now() - WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now() - RETURNING account_id", - ) - .bind(hash_token(token)) - .fetch_optional(pool) - .await?; - Ok(row.map(|(id,)| id)) + consume_email_token(pool, token, EmailTokenPurpose::PasswordReset).await } /// Kill every refresh session of the account: whoever holds a stolen session @@ -112,13 +53,151 @@ pub async fn revoke_all_sessions(pool: &PgPool, account_id: Uuid) -> Result<(), Ok(()) } +/// One outgoing email in the in-process queue. The custom `Debug` is a +/// security invariant, not style: the struct carries the plaintext token, +/// and a `{:?}` anywhere near a log line would publish it. Both fields are +/// therefore permanently redacted. +#[derive(Clone)] +pub struct Mail { + pub to: String, + pub token: String, +} + +impl std::fmt::Debug for Mail { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Mail") + .field("to", &"[redacted]") + .field("token", &"[redacted]") + .finish() + } +} + +/// Delivery back-end for outgoing email. Fail-closed: `Disabled` is the +/// default whenever nothing is configured, and every mail-producing endpoint +/// then answers 503 uniformly instead of silently eating the request. +#[derive(Clone)] +pub enum MailBox { + /// No mail configured (the default). Forgot-password answers 503 for + /// every address alike; registration simply skips the verification mail. + Disabled, + /// Development-only logging back-end, enabled explicitly with + /// `RESET_MAIL_MODE=log`; `Config::validate` refuses to start with it + /// when `COOKIE_SECURE=true`. Logs carry a short address hash only — + /// never the email, never the token (tests get tokens via `Queue`). + Log, + /// In-process queue: tests receive every mail exactly as produced. + Queue(UnboundedSender), + /// Real SMTP through the lettre-backed `SmtpMailer`. + Smtp(Arc, Lang), +} + +impl MailBox { + /// Back-end implied by the configuration: explicit `RESET_MAIL_MODE=log` + /// wins (dev), then SMTP when `SMTP_HOST` is set, else fail-closed. + /// Infallible for Disabled/Log; the SMTP branch fails fast on a + /// malformed `MAIL_FROM` or TLS setup. + pub fn from_config(cfg: &crate::config::Config) -> anyhow::Result { + if cfg.reset_mail_log { + return Ok(MailBox::Log); + } + if cfg.smtp_host.trim().is_empty() { + return Ok(MailBox::Disabled); + } + Ok(MailBox::Smtp( + Arc::new(SmtpMailer::new( + &cfg.smtp_host, + cfg.smtp_port, + &cfg.smtp_user, + &cfg.smtp_password, + &cfg.mail_from, + &cfg.public_base_url, + )?), + cfg.mail_lang, + )) + } + + /// Whether mail can go out at all. When false, mail-producing endpoints + /// answer 503 before touching the database (no timing side channel). + pub fn enabled(&self) -> bool { + !matches!(self, MailBox::Disabled) + } + + pub async fn send_reset(&self, to: &str, token: &str) { + match self { + MailBox::Disabled => {} + MailBox::Log => tracing::info!( + address_tag = %mail::address_tag(to), + "password reset requested (LOG mailer: deliver out of band; the token is not logged)" + ), + MailBox::Queue(tx) => { + let _ = tx.send(Mail { + to: to.to_owned(), + token: token.to_owned(), + }); + } + MailBox::Smtp(mailer, lang) => { + let content = templates::reset_email(*lang, mailer.public_base_url(), token); + self.deliver_smtp(mailer, to, content).await; + } + } + } + + pub async fn send_verify(&self, to: &str, token: &str) { + match self { + MailBox::Disabled => {} + MailBox::Log => tracing::info!( + address_tag = %mail::address_tag(to), + "verification email requested (LOG mailer: deliver out of band; the token is not logged)" + ), + MailBox::Queue(tx) => { + let _ = tx.send(Mail { + to: to.to_owned(), + token: token.to_owned(), + }); + } + MailBox::Smtp(mailer, lang) => { + let content = templates::verify_email(*lang, mailer.public_base_url(), token); + self.deliver_smtp(mailer, to, content).await; + } + } + } + + /// SMTP delivery with secret-free error logging: the recipient appears + /// only as its address tag, and the lettre error is reduced to its + /// permanent/transient flags because SMTP transcripts can echo the + /// recipient address back. + async fn deliver_smtp(&self, mailer: &SmtpMailer, to: &str, content: templates::EmailContent) { + let draft = mail::EmailDraft { + to: to.to_owned(), + subject: content.subject, + text: content.text, + html: content.html, + }; + if let Err(err) = mailer.deliver(draft).await { + // The lettre error is reduced to its flags: SMTP transcripts can + // echo the recipient address back, so the message itself stays + // out of the log. + let smtp = err + .downcast_ref::() + .map(|e| (e.is_permanent(), e.is_transient())); + let (permanent, transient) = smtp.unwrap_or((false, false)); + tracing::error!( + address_tag = %mail::address_tag(to), + permanent, + transient, + "smtp delivery failed" + ); + } + } +} + #[cfg(test)] mod tests { use super::*; #[test] fn well_formed_token_shape_matches_opaque_generator() { - let token = new_opaque_token(TOKEN_PREFIX); + let token = crate::auth::tokens::new_opaque_token(TOKEN_PREFIX); assert!(is_well_formed_token(&token)); assert!(!is_well_formed_token(&format!("{token}x"))); assert!(!is_well_formed_token("lvpr_short")); @@ -126,4 +205,32 @@ mod tests { "XWpr_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" )); } + + #[test] + fn mail_debug_never_shows_the_token_or_the_address() { + let mail = Mail { + to: "secret-user@example.com".into(), + token: "lvpr_SUPER_SECRET_TOKEN_VALUE_1234567890123".into(), + }; + let printed = format!("{mail:?}"); + assert!(!printed.contains("secret-user")); + assert!(!printed.contains("SUPER_SECRET")); + assert!(printed.contains("[redacted]")); + } + + #[test] + fn disabled_mailbox_is_the_only_disabled_one() { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + assert!(!MailBox::Disabled.enabled()); + assert!(MailBox::Log.enabled()); + assert!(MailBox::Queue(tx).enabled()); + } + + #[tokio::test] + async fn disabled_and_log_senders_are_quiet_no_ops() { + MailBox::Disabled + .send_reset("a@example.com", "lvpr_x") + .await; + MailBox::Log.send_verify("a@example.com", "lvev_x").await; + } } diff --git a/backend/accounts-service/src/auth/tokens.rs b/backend/accounts-service/src/auth/tokens.rs index 8be61299..1e529226 100644 --- a/backend/accounts-service/src/auth/tokens.rs +++ b/backend/accounts-service/src/auth/tokens.rs @@ -26,6 +26,81 @@ pub fn hash_token(token: &str) -> String { hex::encode(Sha256::digest(token.as_bytes())) } +/// Purposes of the single-use email-bound tokens in `email_tokens` +/// (mirrors the CHECK constraint in migrations/0006_email_tokens.sql). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmailTokenPurpose { + PasswordReset, + EmailVerify, +} + +impl EmailTokenPurpose { + pub fn as_str(self) -> &'static str { + match self { + EmailTokenPurpose::PasswordReset => "password_reset", + EmailTokenPurpose::EmailVerify => "email_verify", + } + } +} + +/// Issues a fresh single-use token of `purpose` inside one transaction: any +/// still-pending token of the same purpose is invalidated and the new hash +/// stored atomically, so two concurrent requests can never leave two live +/// tokens behind. Returns the plaintext token for the mailer only — it is +/// never persisted in clear form. +pub async fn issue_email_token( + pool: &PgPool, + account_id: Uuid, + purpose: EmailTokenPurpose, + prefix: &str, + ttl_minutes: i64, +) -> Result { + let mut tx = pool.begin().await?; + sqlx::query( + "UPDATE email_tokens SET used_at = now() + WHERE account_id = $1 AND purpose = $2 AND used_at IS NULL", + ) + .bind(account_id) + .bind(purpose.as_str()) + .execute(&mut *tx) + .await?; + let token = new_opaque_token(prefix); + sqlx::query( + "INSERT INTO email_tokens (account_id, purpose, token_hash, expires_at) + VALUES ($1, $2, $3, now() + make_interval(mins => $4::int))", + ) + .bind(account_id) + .bind(purpose.as_str()) + .bind(hash_token(&token)) + .bind(ttl_minutes) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(token) +} + +/// Atomically marks the token as used and returns its account. The single +/// conditional UPDATE makes double-spend impossible even for concurrent +/// callers: exactly one of them gets the row back. `purpose` participates in +/// the WHERE clause, so a verification token can never be replayed as a +/// reset token (or vice versa) even though both live in the same table. +pub async fn consume_email_token( + pool: &PgPool, + token: &str, + purpose: EmailTokenPurpose, +) -> Result, sqlx::Error> { + let row: Option<(Uuid,)> = sqlx::query_as( + "UPDATE email_tokens SET used_at = now() + WHERE token_hash = $1 AND purpose = $2 AND used_at IS NULL AND expires_at > now() + RETURNING account_id", + ) + .bind(hash_token(token)) + .bind(purpose.as_str()) + .fetch_optional(pool) + .await?; + Ok(row.map(|(id,)| id)) +} + pub async fn store_refresh(pool: &PgPool, account_id: Uuid) -> Result { let token = new_opaque_token("lvr_"); sqlx::query( diff --git a/backend/accounts-service/src/auth/verify/handlers.rs b/backend/accounts-service/src/auth/verify/handlers.rs new file mode 100644 index 00000000..c6d69a9b --- /dev/null +++ b/backend/accounts-service/src/auth/verify/handlers.rs @@ -0,0 +1,95 @@ +use crate::accounts::repo; +use crate::auth::verify; +use crate::error::{AppError, AppJson}; +use axum::{Json, extract::State, http::StatusCode}; +use common::internal::GatewayIdentity; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +use super::super::handlers::AuthState; + +#[derive(Deserialize)] +pub struct VerifyEmailRequest { + pub token: String, +} + +#[derive(Serialize)] +pub struct AcceptedResponse { + pub status: &'static str, +} + +/// POST /auth/verify-email { token } +/// +/// Consumes the token atomically and marks the address verified. Bad tokens +/// are a plain 400 with no distinction between unknown, expired and used. +pub async fn verify_email( + State(state): State, + AppJson(req): AppJson, +) -> Result, AppError> { + if !verify::is_well_formed_token(&req.token) { + return Err(AppError::Validation("malformed verification token".into())); + } + let account_id = verify::consume_verify_token(&state.pool, &req.token) + .await? + .ok_or_else(|| AppError::Validation("verification token is invalid or expired".into()))?; + // `AND email_verified_at IS NULL` keeps a replay racing the first click + // from rewinding the timestamp; either way the outcome is "verified". + sqlx::query( + "UPDATE accounts SET email_verified_at = now() + WHERE id = $1 AND email_verified_at IS NULL", + ) + .bind(account_id) + .execute(&state.pool) + .await?; + Ok(Json(AcceptedResponse { + status: "email verified", + })) +} + +/// POST /auth/resend-verification (requires a valid session) +/// +/// Always answers 202 for a signed-in caller, verified or not; the +/// background task simply skips the mail when there is nothing left to +/// verify. Fail-closed: 503 when no mail back-end is configured. +pub async fn resend_verification( + State(state): State, + identity: GatewayIdentity, +) -> Result<(StatusCode, Json), AppError> { + if !state.mail.enabled() { + return Err(AppError::Unavailable); + } + let (mail, pool) = (state.mail.clone(), state.pool.clone()); + tokio::spawn(async move { + if let Err(err) = send_verification_email(&pool, &mail, identity.account_id).await { + tracing::error!( + account_id = %identity.account_id, + "resend-verification background task failed: {err:#}" + ); + } + }); + Ok(( + StatusCode::ACCEPTED, + Json(AcceptedResponse { + status: "verification email sent", + }), + )) +} + +/// Issues a fresh verification token and hands it to the mailer. Shared by +/// registration and resend. A no-op when the address is already verified. +pub(crate) async fn send_verification_email( + pool: &PgPool, + mail_box: &crate::auth::reset::MailBox, + account_id: Uuid, +) -> anyhow::Result<()> { + let Some(account) = repo::find_by_id(pool, account_id).await? else { + return Ok(()); + }; + if account.email_verified_at.is_some() { + return Ok(()); + } + let token = verify::issue_verify_token(pool, account.id).await?; + mail_box.send_verify(&account.email, &token).await; + Ok(()) +} diff --git a/backend/accounts-service/src/auth/verify/mod.rs b/backend/accounts-service/src/auth/verify/mod.rs new file mode 100644 index 00000000..730e1eb2 --- /dev/null +++ b/backend/accounts-service/src/auth/verify/mod.rs @@ -0,0 +1,50 @@ +pub mod handlers; + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::auth::tokens::{EmailTokenPurpose, consume_email_token, issue_email_token}; + +/// Verification links live a day: long enough to find the mail, short +/// enough that a leaked link dies before it matters. +const TTL_MINUTES: i64 = 24 * 60; + +pub const TOKEN_PREFIX: &str = "lvev_"; + +/// Same base64url shape as every other opaque token, own prefix so junk is +/// filtered before the database and a reset link can never be mistaken for +/// a verification link by a user pasting it into the wrong page. +pub fn is_well_formed_token(token: &str) -> bool { + token.len() == TOKEN_PREFIX.len() + 43 && token.starts_with(TOKEN_PREFIX) +} + +pub async fn issue_verify_token(pool: &PgPool, account_id: Uuid) -> Result { + issue_email_token( + pool, + account_id, + EmailTokenPurpose::EmailVerify, + TOKEN_PREFIX, + TTL_MINUTES, + ) + .await +} + +pub async fn consume_verify_token(pool: &PgPool, token: &str) -> Result, sqlx::Error> { + consume_email_token(pool, token, EmailTokenPurpose::EmailVerify).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::tokens::new_opaque_token; + + #[test] + fn well_formed_token_shape_matches_opaque_generator() { + let token = new_opaque_token(TOKEN_PREFIX); + assert!(is_well_formed_token(&token)); + assert!(!is_well_formed_token(&format!("{token}x"))); + assert!(!is_well_formed_token("lvev_short")); + // A reset token is never a valid verification token by shape either. + assert!(!is_well_formed_token(&format!("lvpr_{}", "a".repeat(43)))); + } +} diff --git a/backend/accounts-service/src/config.rs b/backend/accounts-service/src/config.rs index aa86dce9..f2b37324 100644 --- a/backend/accounts-service/src/config.rs +++ b/backend/accounts-service/src/config.rs @@ -1,5 +1,7 @@ use anyhow::{Context, Result}; +use crate::mail::Lang; + #[derive(Clone)] pub struct Config { pub database_url: String, @@ -17,6 +19,21 @@ pub struct Config { /// port-forwarded). Production sets this to the same-origin `/api/media` /// path served back through the gateway, keeping MinIO private. pub avatar_public_base_url: String, + // --- outgoing mail (SMTP; empty `smtp_host` = mail switched off) --- + pub smtp_host: String, + pub smtp_port: u16, + pub smtp_user: String, + pub smtp_password: String, + /// `MAIL_FROM`, e.g. `LoVisual `. + pub mail_from: String, + /// `PUBLIC_BASE_URL`: the only origin email links may carry (host header + /// injection cannot forge links because headers are never consulted). + pub public_base_url: String, + /// `MAIL_LANG`: template language, `ru` by default. + pub mail_lang: Lang, + /// `RESET_MAIL_MODE=log`: the development-only logging mailer. Refused + /// at startup whenever `COOKIE_SECURE=true` (production). + pub reset_mail_log: bool, } impl Config { @@ -41,6 +58,20 @@ impl Config { .map(|v| v != "false") .unwrap_or(true), avatar_public_base_url: std::env::var("AVATAR_PUBLIC_BASE_URL").unwrap_or_default(), + smtp_host: std::env::var("SMTP_HOST").unwrap_or_default(), + smtp_port: std::env::var("SMTP_PORT") + .unwrap_or_else(|_| "587".into()) + .parse() + .context("SMTP_PORT must be a number")?, + smtp_user: std::env::var("SMTP_USER").unwrap_or_default(), + smtp_password: std::env::var("SMTP_PASSWORD").unwrap_or_default(), + mail_from: std::env::var("MAIL_FROM").unwrap_or_default(), + public_base_url: std::env::var("PUBLIC_BASE_URL").unwrap_or_default(), + mail_lang: Lang::parse(&std::env::var("MAIL_LANG").unwrap_or_else(|_| "ru".into())) + .context("MAIL_LANG must be ru or en")?, + reset_mail_log: std::env::var("RESET_MAIL_MODE") + .map(|v| v.trim().eq_ignore_ascii_case("log")) + .unwrap_or(false), }) } } @@ -49,7 +80,8 @@ const MIN_JWT_SECRET_BYTES: usize = 32; impl Config { /// Fail fast at startup on a secret too weak to sign HS256 tokens with - /// (the `.env.example` placeholder must never reach production). + /// (the `.env.example` placeholder must never reach production), and on + /// mail settings that would silently break or weaken delivery. pub fn validate(&self) -> Result<()> { if self.jwt_secret.len() < MIN_JWT_SECRET_BYTES { anyhow::bail!("JWT_SECRET must be at least {MIN_JWT_SECRET_BYTES} bytes"); @@ -57,6 +89,22 @@ impl Config { if self.internal_key.len() < MIN_JWT_SECRET_BYTES { anyhow::bail!("INTERNAL_KEY must be at least {MIN_JWT_SECRET_BYTES} bytes"); } + // The log mailer prints no tokens at all, but it also delivers + // nothing: on production it would strand every reset/verification + // request, so it is dev-only by construction. + if self.reset_mail_log && self.cookie_secure { + anyhow::bail!( + "RESET_MAIL_MODE=log is a development-only mailer and must not run with COOKIE_SECURE=true" + ); + } + if !self.smtp_host.trim().is_empty() { + if self.mail_from.trim().is_empty() { + anyhow::bail!("MAIL_FROM is required when SMTP_HOST is set"); + } + if self.public_base_url.trim().is_empty() { + anyhow::bail!("PUBLIC_BASE_URL is required when SMTP_HOST is set"); + } + } Ok(()) } @@ -92,6 +140,14 @@ mod tests { s3_secret_key: String::new(), cookie_secure: false, avatar_public_base_url: String::new(), + smtp_host: String::new(), + smtp_port: 587, + smtp_user: String::new(), + smtp_password: String::new(), + mail_from: String::new(), + public_base_url: String::new(), + mail_lang: Lang::default(), + reset_mail_log: false, } } @@ -112,6 +168,31 @@ mod tests { assert!(config_with_secret(&"x".repeat(32)).validate().is_ok()); } + #[test] + fn log_mail_mode_is_refused_in_production() { + let mut cfg = config_with_secret(&"x".repeat(32)); + cfg.reset_mail_log = true; + assert!(cfg.validate().is_ok(), "log mode is fine for dev"); + cfg.cookie_secure = true; + assert!(cfg.validate().is_err(), "log mode must not run in prod"); + } + + #[test] + fn smtp_requires_sender_and_base_url() { + let mut cfg = config_with_secret(&"x".repeat(32)); + cfg.smtp_host = "smtp.resend.com".into(); + assert!(cfg.validate().is_err()); + cfg.mail_from = "LoVisual ".into(); + assert!(cfg.validate().is_err()); + cfg.public_base_url = "https://visual.loki-code.dev".into(); + assert!(cfg.validate().is_ok()); + } + + #[test] + fn mail_off_by_default_passes_validation() { + assert!(config_with_secret(&"x".repeat(32)).validate().is_ok()); + } + #[test] fn avatar_base_url_joins_endpoint_and_bucket_without_double_slash() { let mut cfg = config_with_secret(&"x".repeat(32)); diff --git a/backend/accounts-service/src/device/handlers.rs b/backend/accounts-service/src/device/handlers.rs index efe6e9af..5e92b70b 100644 --- a/backend/accounts-service/src/device/handlers.rs +++ b/backend/accounts-service/src/device/handlers.rs @@ -48,6 +48,16 @@ pub async fn confirm( identity: GatewayIdentity, AppJson(req): AppJson, ) -> Result { + // The device link is the one credential that speaks to the backend with + // no site session at all, so it gates on a confirmed address: without + // that, a single mistyped character during sign-up would hand the + // account to a stranger's inbox typo domain. + let account = crate::accounts::repo::find_by_id(&state.pool, identity.account_id) + .await? + .ok_or(AppError::Unauthorized)?; + if account.email_verified_at.is_none() { + return Err(AppError::Forbidden("email not verified".into())); + } if state.store.confirm(&req.user_code, identity.account_id) { Ok(StatusCode::OK) } else { diff --git a/backend/accounts-service/src/device/links.rs b/backend/accounts-service/src/device/links.rs index 8af41338..102ab250 100644 --- a/backend/accounts-service/src/device/links.rs +++ b/backend/accounts-service/src/device/links.rs @@ -22,12 +22,16 @@ pub async fn create(pool: &PgPool, account_id: Uuid) -> Result Result, sqlx::Error> { - sqlx::query_scalar( - "UPDATE device_links SET last_seen = now() WHERE device_token_hash = $1 RETURNING account_id", +/// Resolves a device token to its account (plus the account's email +/// verification state for callers that gate capabilities on it) and bumps +/// `last_seen`. Returns `None` for unknown tokens (and for nothing else — +/// revocation deletes the row, so revoked tokens are just unknown). +pub async fn authenticate(pool: &PgPool, token: &str) -> Result, sqlx::Error> { + sqlx::query_as( + "UPDATE device_links dl SET last_seen = now() + FROM accounts a + WHERE dl.device_token_hash = $1 AND a.id = dl.account_id + RETURNING dl.account_id, a.email_verified_at IS NOT NULL", ) .bind(hash_token(token)) .fetch_optional(pool) diff --git a/backend/accounts-service/src/error.rs b/backend/accounts-service/src/error.rs index 6615b5a3..83af2563 100644 --- a/backend/accounts-service/src/error.rs +++ b/backend/accounts-service/src/error.rs @@ -11,8 +11,13 @@ pub enum AppError { Validation(String), Conflict(String), Unauthorized, + Forbidden(String), NotFound(String), TooManyRequests, + /// The request is fine but a required back-end is switched off (e.g. + /// outgoing mail). Returned for every caller alike so it cannot be used + /// as an oracle for anything. + Unavailable, Internal(anyhow::Error), } @@ -22,7 +27,12 @@ impl IntoResponse for AppError { AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg), AppError::Conflict(msg) => (StatusCode::CONFLICT, msg), AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()), + AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg), AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), + AppError::Unavailable => ( + StatusCode::SERVICE_UNAVAILABLE, + "service is not available right now".into(), + ), AppError::TooManyRequests => ( StatusCode::TOO_MANY_REQUESTS, "too many pending device codes".into(), diff --git a/backend/accounts-service/src/grpc/mod.rs b/backend/accounts-service/src/grpc/mod.rs index a2eda701..0f2eda52 100644 --- a/backend/accounts-service/src/grpc/mod.rs +++ b/backend/accounts-service/src/grpc/mod.rs @@ -46,8 +46,9 @@ impl AccountsInternal for AccountsGrpc { ) -> Result, Status> { let token = request.into_inner().device_token; match crate::device::links::authenticate(&self.pool, &token).await { - Ok(Some(account_id)) => Ok(Response::new(AuthenticateDeviceReply { + Ok(Some((account_id, email_verified))) => Ok(Response::new(AuthenticateDeviceReply { account_id: account_id.to_string(), + email_verified, })), Ok(None) => Err(Status::unauthenticated("unknown or revoked device token")), Err(err) => { diff --git a/backend/accounts-service/src/lib.rs b/backend/accounts-service/src/lib.rs index c95bd475..d020ba24 100644 --- a/backend/accounts-service/src/lib.rs +++ b/backend/accounts-service/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod device; pub mod error; pub mod grpc; +pub mod mail; use accounts::handlers::AccountsState; use auth::handlers::AuthState; @@ -19,11 +20,12 @@ use device::{handlers::DeviceState, store::DeviceStore}; use tower_http::trace::TraceLayer; pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router { - build_app_with_mail(pool, cfg, auth::reset::MailBox::Log) + let mail = auth::reset::MailBox::from_config(cfg).expect("invalid mail configuration"); + build_app_with_mail(pool, cfg, mail) } -/// Same router with a custom reset-email back-end: production uses the log -/// mailer, tests capture tokens through the queue variant. +/// Same router with an explicit mail back-end: production derives it from +/// the config (SMTP / LOG / Disabled), tests capture tokens via the queue. pub fn build_app_with_mail(pool: sqlx::PgPool, cfg: &Config, mail: auth::reset::MailBox) -> Router { let auth_state = AuthState::with_mail( pool.clone(), @@ -63,6 +65,14 @@ pub fn build_app_with_mail(pool: sqlx::PgPool, cfg: &Config, mail: auth::reset:: "/auth/reset-password", post(auth::reset::handlers::reset_password), ) + .route( + "/auth/verify-email", + post(auth::verify::handlers::verify_email), + ) + .route( + "/auth/resend-verification", + post(auth::verify::handlers::resend_verification), + ) .with_state(auth_state); let device_routes = Router::new() diff --git a/backend/accounts-service/src/mail/mod.rs b/backend/accounts-service/src/mail/mod.rs new file mode 100644 index 00000000..6a0cd828 --- /dev/null +++ b/backend/accounts-service/src/mail/mod.rs @@ -0,0 +1,177 @@ +//! Outgoing email: the `Mailer` transports, the SMTP/no-op implementations +//! and the shared, non-reversible log tag for addresses. +//! +//! Secrets discipline: no caller ever passes a plaintext token into a log +//! line; templates are the only place user-visible mail text exists, and +//! their dynamic input is escaped there. + +pub mod templates; + +use std::future::Future; + +use anyhow::{Context, Result}; +use lettre::{ + AsyncSmtpTransport, AsyncTransport, Tokio1Executor, + message::{Mailbox, Message, MultiPart}, + transport::smtp::{ + authentication::Credentials, + client::{Tls, TlsParameters}, + }, +}; +use sha2::{Digest, Sha256}; + +/// Language of the built-in email templates (`MAIL_LANG`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Lang { + #[default] + Ru, + En, +} + +impl Lang { + pub fn parse(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "ru" => Some(Lang::Ru), + "en" => Some(Lang::En), + _ => None, + } + } +} + +/// A fully rendered email ready for any transport. Templates escape dynamic +/// content, so a draft is safe to hand to a transport as-is. +pub struct EmailDraft { + pub to: String, + pub subject: String, + pub text: String, + pub html: String, +} + +/// Transport abstraction. One method keeps fakes trivial. Desugared to an +/// explicit `Send` future (rather than AFIT) so the trait stays usable from +/// `tokio::spawn`ed background tasks without hidden auto-trait surprises. +pub trait Mailer { + fn deliver(&self, draft: EmailDraft) -> impl Future> + Send; +} + +/// Real SMTP via lettre. TLS only: 465 speaks implicit TLS, every other port +/// uses required STARTTLS (no plaintext downgrade for password mail). rustls +/// everywhere — no native-tls in the dependency tree. +pub struct SmtpMailer { + transport: AsyncSmtpTransport, + from: Mailbox, + public_base_url: String, +} + +impl SmtpMailer { + /// `from` is a full mailbox (`LoVisual ` or a bare + /// address); `public_base_url` is the only link origin emails may carry. + pub fn new( + host: &str, + port: u16, + user: &str, + password: &str, + from: &str, + public_base_url: &str, + ) -> Result { + let tls = TlsParameters::builder(host.to_owned()) + .build() + .context("building SMTP TLS parameters")?; + let mut builder = AsyncSmtpTransport::::builder_dangerous(host.to_owned()) + .port(port) + .tls(if port == 465 { + Tls::Wrapper(tls) + } else { + Tls::Required(tls) + }); + if !user.trim().is_empty() { + builder = builder.credentials(Credentials::new(user.to_owned(), password.to_owned())); + } + Ok(SmtpMailer { + transport: builder.build(), + from: from.parse().context("MAIL_FROM is not a valid mailbox")?, + public_base_url: public_base_url.trim().trim_end_matches('/').to_owned(), + }) + } + + /// Link origin for email buttons, taken from config only — never from + /// request headers (host header injection would forge phishing links). + pub fn public_base_url(&self) -> &str { + &self.public_base_url + } +} + +impl Mailer for SmtpMailer { + async fn deliver(&self, draft: EmailDraft) -> Result<()> { + let mail = Message::builder() + .from(self.from.clone()) + .to(draft + .to + .parse() + .context("recipient is not a valid mailbox")?) + .subject(draft.subject) + .multipart(MultiPart::alternative_plain_html(draft.text, draft.html))?; + self.transport.send(mail).await?; + Ok(()) + } +} + +/// Stand-in transport for dev runs that deliberately skip SMTP: reports +/// success and logs the fact, so callers need no disabled branch. Only +/// non-confidential metadata is logged. +pub struct NoopMailer; + +impl Mailer for NoopMailer { + async fn deliver(&self, draft: EmailDraft) -> Result<()> { + tracing::info!(subject = %draft.subject, "no-op mailer: delivery suppressed (no SMTP configured)"); + Ok(()) + } +} + +/// Short non-reversible log tag for an address: enough to correlate log +/// lines for the same recipient across requests, useless for rebuilding the +/// address or matching it against a candidate list. +pub fn address_tag(email: &str) -> String { + let digest = Sha256::digest(email.trim().to_lowercase().as_bytes()); + digest[..4].iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lang_parses_both_locales_and_nothing_else() { + assert_eq!(Lang::parse("ru"), Some(Lang::Ru)); + assert_eq!(Lang::parse("EN"), Some(Lang::En)); + assert_eq!(Lang::parse("de"), None); + assert_eq!(Lang::parse(""), None); + } + + #[test] + fn address_tag_is_short_stable_and_not_the_address() { + let a = address_tag("User@Example.com "); + let b = address_tag("user@example.com"); + assert_eq!(a, b, "tag must normalize case and whitespace"); + assert_eq!(a.len(), 8); + assert!(!a.contains("user")); + } + + #[test] + fn address_tag_differs_per_address() { + assert_ne!(address_tag("a@example.com"), address_tag("b@example.com")); + } + + #[tokio::test] + async fn noop_mailer_reports_success_without_sending() { + NoopMailer + .deliver(EmailDraft { + to: "a@example.com".into(), + subject: "s".into(), + text: "t".into(), + html: "

t

".into(), + }) + .await + .expect("no-op delivery must succeed"); + } +} diff --git a/backend/accounts-service/src/mail/templates.rs b/backend/accounts-service/src/mail/templates.rs new file mode 100644 index 00000000..cca1793c --- /dev/null +++ b/backend/accounts-service/src/mail/templates.rs @@ -0,0 +1,170 @@ +use super::Lang; + +/// Rendered email content: subject plus plain-text and HTML bodies. +pub struct EmailContent { + pub subject: String, + pub text: String, + pub html: String, +} + +/// Escapes the five characters that matter in HTML text and attribute +/// values. Today the only dynamic input is the link (a trusted-config base +/// URL plus our own base64url token), but escaping stays mandatory so a +/// future template edit cannot silently re-open an HTML-injection hole. +pub fn escape_html(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +/// The only link origin is `PUBLIC_BASE_URL` from config; request headers +/// are never consulted (host header injection would forge phishing links). +fn link(base_url: &str, path: &str, token: &str) -> String { + format!( + "{}{path}?token={}", + base_url.trim().trim_end_matches('/'), + token + ) +} + +fn build(subject: &str, text: String, html: String) -> EmailContent { + EmailContent { + subject: subject.to_owned(), + text, + html, + } +} + +/// Renders the HTML body with every dynamic value escaped: the link origin +/// comes from config, but the config is still data, not markup. +fn html_body(body_template: &str, url: &str) -> String { + body_template.replace("{URL}", &escape_html(url)) +} + +/// Password reset: one button, 30-minute token, generic wording (the mail +/// itself must not reveal whether the address even has an account). +pub fn reset_email(lang: Lang, base_url: &str, token: &str) -> EmailContent { + let url = link(base_url, "/reset-password", token); + match lang { + Lang::Ru => build( + "Сброс пароля LoVisual", + format!( + "Кто-то запросил сброс пароля LoVisual.\n\ + Если это были вы, открой ссылку (действует 30 минут):\n{url}\n\n\ + Если нет — просто проигнорируйте письмо, пароль не менялся." + ), + html_body( + "

Кто-то запросил сброс пароля LoVisual.

\ +

Если это были вы, нажмите кнопку (действует 30 минут):

\ +

Сбросить пароль

\ +

Если нет — просто проигнорируйте письмо, пароль не менялся.

", + &url, + ), + ), + Lang::En => build( + "LoVisual password reset", + format!( + "Someone requested a password reset for LoVisual.\n\ + If it was you, open the link (valid for 30 minutes):\n{url}\n\n\ + If not, just ignore this email - the password was not changed." + ), + html_body( + "

Someone requested a password reset for LoVisual.

\ +

If it was you, press the button (valid for 30 minutes):

\ +

Reset password

\ +

If not, just ignore this email - the password was not changed.

", + &url, + ), + ), + } +} + +/// Address verification: one button, 24-hour token. +pub fn verify_email(lang: Lang, base_url: &str, token: &str) -> EmailContent { + let url = link(base_url, "/verify", token); + match lang { + Lang::Ru => build( + "Подтверждение почты LoVisual", + format!( + "Добро пожаловать в LoVisual!\n\ + Подтвердите почту по ссылке (действует 24 часа):\n{url}\n\n\ + Без подтверждения нельзя привязать мод к аккаунту." + ), + html_body( + "

Добро пожаловать в LoVisual!

\ +

Подтвердите почту по ссылке (действует 24 часа):

\ +

Подтвердить почту

\ +

Без подтверждения нельзя привязать мод к аккаунту.

", + &url, + ), + ), + Lang::En => build( + "LoVisual email verification", + format!( + "Welcome to LoVisual!\n\ + Verify your email via the link (valid for 24 hours):\n{url}\n\n\ + Device linking stays locked until the address is verified." + ), + html_body( + "

Welcome to LoVisual!

\ +

Verify your email via the link (valid for 24 hours):

\ +

Verify email

\ +

Device linking stays locked until the address is verified.

", + &url, + ), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BASE: &str = "https://visual.loki-code.dev/"; + const TOKEN: &str = "lvev_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + #[test] + fn links_are_built_from_config_base_without_double_slash() { + let mail = verify_email(Lang::Ru, BASE, TOKEN); + assert!( + mail.html + .contains("https://visual.loki-code.dev/verify?token=lvev_") + ); + assert!(!mail.html.contains("dev//verify")); + } + + #[test] + fn dynamic_content_is_html_escaped() { + // A hostile base URL must not be able to break out of the attribute. + let evil = "https://x.example\">"; + let mail = verify_email(Lang::En, evil, TOKEN); + assert!(!mail.html.contains("