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
This commit is contained in:
loki5512344 2026-10-09 21:08:33 +02:00
parent 12ce6216b6
commit c57f851a8b
Signed by: boba
GPG key ID: 253067914055423B
48 changed files with 1810 additions and 120 deletions

View file

@ -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 <noreply@loki-code.dev>
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=

54
backend/Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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;

View file

@ -22,6 +22,9 @@ pub struct MeResponse {
pub display_nick: String,
pub role: String,
pub avatar_url: Option<String>,
/// Drives the site's "confirm your email" banner and the mod's
/// verification prompt.
pub email_verified: bool,
pub created_at: DateTime<Utc>,
}
@ -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,
}))
}

View file

@ -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<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
@ -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<String, AppError> {
let email = email.trim();
if email.is_empty() {
@ -44,6 +50,11 @@ pub fn normalize_email(email: &str) -> Result<String, AppError> {
"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());
}
}

View file

@ -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<Option<Account>, 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<Option<Account>
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Account>, 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)

View file

@ -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 {

View file

@ -2,3 +2,4 @@ pub mod handlers;
pub mod password;
pub mod reset;
pub mod tokens;
pub mod verify;

View file

@ -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<AuthState>,
AppJson(req): AppJson<ForgotPasswordRequest>,
) -> Result<(StatusCode, Json<AcceptedResponse>), 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,

View file

@ -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<Mail>),
}
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<String, sqlx::Error> {
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<Option<Uuid>, 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<Mail>),
/// Real SMTP through the lettre-backed `SmtpMailer`.
Smtp(Arc<SmtpMailer>, 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<MailBox> {
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::<lettre::transport::smtp::Error>()
.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;
}
}

View file

@ -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<String, sqlx::Error> {
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<Option<Uuid>, 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<String, sqlx::Error> {
let token = new_opaque_token("lvr_");
sqlx::query(

View file

@ -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<AuthState>,
AppJson(req): AppJson<VerifyEmailRequest>,
) -> Result<Json<AcceptedResponse>, 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<AuthState>,
identity: GatewayIdentity,
) -> Result<(StatusCode, Json<AcceptedResponse>), 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(())
}

View file

@ -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<String, sqlx::Error> {
issue_email_token(
pool,
account_id,
EmailTokenPurpose::EmailVerify,
TOKEN_PREFIX,
TTL_MINUTES,
)
.await
}
pub async fn consume_verify_token(pool: &PgPool, token: &str) -> Result<Option<Uuid>, 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))));
}
}

View file

@ -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 <noreply@loki-code.dev>`.
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 <noreply@loki-code.dev>".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));

View file

@ -48,6 +48,16 @@ pub async fn confirm(
identity: GatewayIdentity,
AppJson(req): AppJson<ConfirmRequest>,
) -> Result<StatusCode, AppError> {
// 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 {

View file

@ -22,12 +22,16 @@ pub async fn create(pool: &PgPool, account_id: Uuid) -> Result<String, sqlx::Err
Ok(token)
}
/// Resolves a device token to its account 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<Option<Uuid>, 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<Option<(Uuid, bool)>, 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)

View file

@ -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(),

View file

@ -46,8 +46,9 @@ impl AccountsInternal for AccountsGrpc {
) -> Result<Response<AuthenticateDeviceReply>, 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) => {

View file

@ -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()

View file

@ -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<Lang> {
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<Output = Result<()>> + 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<Tokio1Executor>,
from: Mailbox,
public_base_url: String,
}
impl SmtpMailer {
/// `from` is a full mailbox (`LoVisual <noreply@loki-code.dev>` 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<Self> {
let tls = TlsParameters::builder(host.to_owned())
.build()
.context("building SMTP TLS parameters")?;
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::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: "<p>t</p>".into(),
})
.await
.expect("no-op delivery must succeed");
}
}

View file

@ -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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => 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(
"<p>Кто-то запросил сброс пароля LoVisual.</p>\
<p>Если это были вы, нажмите кнопку (действует 30 минут):</p>\
<p><a href=\"{URL}\">Сбросить пароль</a></p>\
<p>Если нет — просто проигнорируйте письмо, пароль не менялся.</p>",
&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(
"<p>Someone requested a password reset for LoVisual.</p>\
<p>If it was you, press the button (valid for 30 minutes):</p>\
<p><a href=\"{URL}\">Reset password</a></p>\
<p>If not, just ignore this email - the password was not changed.</p>",
&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(
"<p>Добро пожаловать в LoVisual!</p>\
<p>Подтвердите почту по ссылке (действует 24 часа):</p>\
<p><a href=\"{URL}\">Подтвердить почту</a></p>\
<p>Без подтверждения нельзя привязать мод к аккаунту.</p>",
&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(
"<p>Welcome to LoVisual!</p>\
<p>Verify your email via the link (valid for 24 hours):</p>\
<p><a href=\"{URL}\">Verify email</a></p>\
<p>Device linking stays locked until the address is verified.</p>",
&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\"><script>alert(1)</script>";
let mail = verify_email(Lang::En, evil, TOKEN);
assert!(!mail.html.contains("<script>"));
assert!(mail.html.contains("&lt;script&gt;"));
}
#[test]
fn both_locales_render_distinct_subjects() {
let ru = reset_email(Lang::Ru, BASE, TOKEN);
let en = reset_email(Lang::En, BASE, TOKEN);
assert_ne!(ru.subject, en.subject);
assert!(en.text.contains("/reset-password?token="));
assert!(ru.text.contains("/reset-password?token="));
}
#[test]
fn verify_and_reset_point_to_their_own_pages() {
let reset = reset_email(Lang::En, BASE, TOKEN);
let verify = verify_email(Lang::En, BASE, TOKEN);
assert!(reset.html.contains("/reset-password?token="));
assert!(verify.html.contains("/verify?token="));
}
}

View file

@ -38,6 +38,14 @@ pub fn test_config() -> Config {
s3_secret_key: "minioadmin".into(),
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: accounts_service::mail::Lang::default(),
reset_mail_log: false,
}
}
@ -50,7 +58,9 @@ pub fn test_server(app: axum::Router) -> TestServer {
server
}
/// Registers a fresh random account; returns its id and email.
/// Registers a fresh random account; returns its id and email. The account
/// starts unverified, exactly like a real sign-up (device linking therefore
/// needs `mark_verified` first).
pub async fn register_account(server: &TestServer) -> (Uuid, String) {
let email = format!("t-{}@example.com", Uuid::new_v4());
let res = server
@ -65,6 +75,17 @@ pub async fn register_account(server: &TestServer) -> (Uuid, String) {
)
}
/// Marks the account's email as verified in place. Stand-in for the real
/// `/auth/verify-email` round trip in flows that only need a verified
/// account (device linking, avatars).
pub async fn mark_verified(pool: &sqlx::PgPool, account_id: Uuid) {
sqlx::query("UPDATE accounts SET email_verified_at = now() WHERE id = $1")
.bind(account_id)
.execute(pool)
.await
.expect("mark account verified");
}
/// Creates the test avatar bucket if it does not exist yet, so the avatar
/// tests are self-contained: any S3-compatible backend (MinIO, SeaweedFS)
/// works without external `mc mb` bootstrap. Mirrors `S3Storage::from_config`.

View file

@ -15,6 +15,7 @@ async fn full_device_link_flow() {
&common::test_config(),
));
let (account_id, email) = common::register_account(&server).await;
common::mark_verified(&pool, account_id).await;
let code_response: serde_json::Value = server.post("/device/code").await.json();
let device_code = code_response["device_code"].as_str().unwrap();
@ -82,6 +83,7 @@ async fn unknown_device_code_and_user_code_return_404() {
&common::test_config(),
));
let (account_id, email) = common::register_account(&server).await;
common::mark_verified(&pool, account_id).await;
server
.post("/device/token")

View file

@ -29,13 +29,14 @@ async fn confirmed_device_gets_an_opaque_token_backed_by_a_link_row() {
&common::test_config(),
));
let (account, _) = common::register_account(&server).await;
common::mark_verified(&pool, account).await;
let token = link_device(&server, account).await;
assert!(token.starts_with("lvd_"));
let resolved = accounts_service::device::links::authenticate(&pool, &token)
.await
.unwrap();
assert_eq!(resolved, Some(account));
assert_eq!(resolved, Some((account, true)));
let links: Vec<serde_json::Value> = server
.get("/device/links")
.add_header(ACCOUNT_ID_HEADER, account.to_string())
@ -56,6 +57,7 @@ async fn revoking_a_link_kills_its_token_only() {
&common::test_config(),
));
let (account, _) = common::register_account(&server).await;
common::mark_verified(&pool, account).await;
let t1 = link_device(&server, account).await;
let t2 = link_device(&server, account).await;
@ -88,9 +90,13 @@ async fn revoking_a_link_kills_its_token_only() {
#[tokio::test]
async fn cannot_revoke_someone_elses_link() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
let (owner, _) = common::register_account(&server).await;
let (stranger, _) = common::register_account(&server).await;
common::mark_verified(&pool, owner).await;
link_device(&server, owner).await;
let links: Vec<serde_json::Value> = server
.get("/device/links")
@ -123,6 +129,7 @@ async fn self_revoke_by_token_deletes_only_that_link() {
&common::test_config(),
));
let (account, _) = common::register_account(&server).await;
common::mark_verified(&pool, account).await;
let t1 = link_device(&server, account).await;
let t2 = link_device(&server, account).await;
@ -145,5 +152,5 @@ async fn self_revoke_by_token_deletes_only_that_link() {
.await
.unwrap();
assert_eq!(gone, None);
assert_eq!(kept, Some(account));
assert_eq!(kept, Some((account, true)));
}

View file

@ -0,0 +1,212 @@
//! Email verification flow: registration queues the mail, the token marks
//! the address verified once, resend re-issues, and unverified accounts are
//! locked out of device linking.
mod common;
use accounts_service::auth::reset::{Mail, MailBox};
use axum::http::StatusCode;
use axum_test::TestServer;
use serde_json::json;
use sqlx::PgPool;
use std::time::Duration;
use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
type App = (TestServer, PgPool, UnboundedReceiver<Mail>);
async fn app() -> App {
let (tx, rx) = unbounded_channel();
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app_with_mail(
pool.clone(),
&common::test_config(),
MailBox::Queue(tx),
));
(server, pool, rx)
}
/// Waits for the next verification email (the only mail these tests queue)
/// with a timeout, so a broken background task fails loudly instead of
/// hanging the suite.
async fn wait_verify_mail(mail: &mut UnboundedReceiver<Mail>) -> Mail {
loop {
let m = tokio::time::timeout(Duration::from_secs(5), mail.recv())
.await
.expect("background mail task must finish within 5s")
.expect("queue mailer must deliver");
if m.token.starts_with("lvev_") {
return m;
}
}
}
async fn login(server: &TestServer, email: &str) -> String {
let res = server
.post("/auth/login")
.json(&json!({ "email": email, "password": "correct-horse-battery-staple" }))
.await;
res.assert_status_ok();
let body: serde_json::Value = res.json();
body["access_token"].as_str().unwrap().to_owned()
}
async fn me(server: &TestServer, account_id: &str, bearer: &str) -> serde_json::Value {
server
.get("/me")
.add_header(common::ACCOUNT_ID_HEADER, account_id)
.add_header("authorization", format!("Bearer {bearer}"))
.await
.json::<serde_json::Value>()
}
#[tokio::test]
async fn registration_sends_mail_and_verification_round_trip_works() {
let (server, _pool, mut mail) = app().await;
let (account_id, email) = common::register_account(&server).await;
// Registration answered before the mail went out; the token arrives
// from the background task.
let sent = wait_verify_mail(&mut mail).await;
assert_eq!(sent.to, email);
assert!(sent.token.starts_with("lvev_"));
let bearer = login(&server, &email).await;
let before = me(&server, &account_id.to_string(), &bearer).await;
assert_eq!(before["email_verified"], false);
server
.post("/auth/verify-email")
.json(&json!({ "token": sent.token }))
.await
.assert_status_ok();
let after = me(&server, &account_id.to_string(), &bearer).await;
assert_eq!(after["email_verified"], true);
}
#[tokio::test]
async fn verification_token_is_single_use_and_shape_checked() {
let (server, _pool, mut mail) = app().await;
common::register_account(&server).await;
let sent = wait_verify_mail(&mut mail).await;
server
.post("/auth/verify-email")
.json(&json!({ "token": sent.token }))
.await
.assert_status_ok();
// Second click: burned.
server
.post("/auth/verify-email")
.json(&json!({ "token": sent.token }))
.await
.assert_status(StatusCode::BAD_REQUEST);
// Wrong prefix is a shape rejection, same 400 family, no oracle.
server
.post("/auth/verify-email")
.json(&json!({ "token": "lvpr_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn resend_requires_login_and_reissues_a_working_token() {
let (server, _pool, mut mail) = app().await;
let (account_id, email) = common::register_account(&server).await;
let first = wait_verify_mail(&mut mail).await.token;
// Anonymous (no identity header): 401, and no second mail.
server
.post("/auth/resend-verification")
.await
.assert_status(StatusCode::UNAUTHORIZED);
let bearer = login(&server, &email).await;
// The gateway resolves the bearer into the identity header; the test
// server plays its part.
server
.post("/auth/resend-verification")
.add_header(common::ACCOUNT_ID_HEADER, account_id.to_string())
.add_header("authorization", format!("Bearer {bearer}"))
.await
.assert_status(StatusCode::ACCEPTED);
let second = wait_verify_mail(&mut mail).await.token;
assert_ne!(first, second);
// The superseded token is dead, the fresh one works.
server
.post("/auth/verify-email")
.json(&json!({ "token": first }))
.await
.assert_status(StatusCode::BAD_REQUEST);
server
.post("/auth/verify-email")
.json(&json!({ "token": second }))
.await
.assert_status_ok();
}
#[tokio::test]
async fn unverified_account_cannot_link_a_device_until_verified() {
let (server, pool, mut mail) = app().await;
let (account_id, email) = common::register_account(&server).await;
let code: serde_json::Value = server.post("/device/code").await.json();
server
.post("/device/confirm")
.add_header(common::ACCOUNT_ID_HEADER, account_id.to_string())
.json(&json!({ "user_code": code["user_code"] }))
.await
.assert_status(StatusCode::FORBIDDEN);
// The same code is still pending: the 403 must not have consumed it.
let sent = wait_verify_mail(&mut mail).await;
server
.post("/auth/verify-email")
.json(&json!({ "token": sent.token }))
.await
.assert_status_ok();
server
.post("/device/confirm")
.add_header(common::ACCOUNT_ID_HEADER, account_id.to_string())
.json(&json!({ "user_code": code["user_code"] }))
.await
.assert_status_ok();
sqlx::query("DELETE FROM accounts WHERE id = $1")
.bind(account_id)
.execute(&pool)
.await
.unwrap();
let _ = email;
}
#[tokio::test]
async fn reset_and_verify_tokens_cannot_stand_in_for_each_other() {
let (server, _pool, mut mail) = app().await;
let (_, email) = common::register_account(&server).await;
let verify_token = wait_verify_mail(&mut mail).await.token;
// A reset token can never verify (shape), and this verify token can
// never reset: the endpoint's prefix check rejects it before the DB.
let res = server
.post("/auth/reset-password")
.json(&json!({ "token": verify_token, "new_password": "brand-new-password-1" }))
.await;
res.assert_status(StatusCode::BAD_REQUEST);
// The login password is untouched, and the verification token still
// works — the failed cross-use did not consume it.
server
.post("/auth/login")
.json(&json!({ "email": email, "password": "correct-horse-battery-staple" }))
.await
.assert_status_ok();
server
.post("/auth/verify-email")
.json(&json!({ "token": verify_token }))
.await
.assert_status_ok();
}

View file

@ -4,6 +4,7 @@ use accounts_service::auth::reset::{Mail, MailBox};
use axum::http::StatusCode;
use axum_test::TestServer;
use sqlx::PgPool;
use std::time::Duration;
use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
type App = (TestServer, PgPool, UnboundedReceiver<Mail>);
@ -19,6 +20,30 @@ async fn app() -> App {
(server, pool, rx)
}
/// Waits for the next reset email, skipping verification emails that
/// registration queues in the background. The timeout keeps a broken
/// background task from hanging the test suite; the skip matters because
/// every registration now sends its own mail.
async fn wait_reset_mail(mail: &mut UnboundedReceiver<Mail>) -> Mail {
loop {
let m = tokio::time::timeout(Duration::from_secs(5), mail.recv())
.await
.expect("background mail task must finish within 5s")
.expect("queue mailer must deliver");
if m.token.starts_with("lvpr_") {
return m;
}
}
}
/// Asserts that no email arrives in the immediate future. Used for the
/// unknown-address case: the handler returns 202 before the background task
/// even starts, so a bare `try_recv` would race the spawn.
async fn assert_no_mail(mail: &mut UnboundedReceiver<Mail>) {
let raced = tokio::time::timeout(Duration::from_millis(300), mail.recv()).await;
assert!(raced.is_err(), "no mail must be queued, got {raced:?}");
}
async fn request_reset(server: &TestServer, email: &str) {
server
.post("/auth/forgot-password")
@ -76,7 +101,7 @@ async fn full_reset_flow_changes_password_and_kills_sessions() {
let old_refresh = refresh_cookie(&login).expect("login must set the refresh cookie");
request_reset(&server, &email).await;
let token = mail.recv().await.expect("queue mailer must deliver").token;
let token = wait_reset_mail(&mut mail).await.token;
reset_with(&server, &token, "brand-new-password-1")
.await
@ -110,7 +135,7 @@ async fn reset_token_is_single_use() {
let (_, email) = common::register_account(&server).await;
request_reset(&server, &email).await;
let token = mail.recv().await.expect("mail").token;
let token = wait_reset_mail(&mut mail).await.token;
reset_with(&server, &token, "brand-new-password-1")
.await
@ -126,9 +151,12 @@ async fn new_request_supersedes_pending_token() {
let (_, email) = common::register_account(&server).await;
request_reset(&server, &email).await;
let first = mail.recv().await.expect("mail").token;
let first = wait_reset_mail(&mut mail).await.token;
// The first mail only arrives after the background task finished its
// UPDATE + INSERT, so the second request's task is guaranteed to
// invalidate `first` — no spawn-order race in the assertions below.
request_reset(&server, &email).await;
let second = mail.recv().await.expect("mail").token;
let second = wait_reset_mail(&mut mail).await.token;
assert_ne!(first, second);
// The superseded token no longer works, the fresh one does.
@ -171,7 +199,7 @@ async fn weak_password_is_rejected_before_token_consumption() {
let (_, email) = common::register_account(&server).await;
request_reset(&server, &email).await;
let token = mail.recv().await.expect("mail").token;
let token = wait_reset_mail(&mut mail).await.token;
reset_with(&server, &token, "short12")
.await
@ -188,15 +216,14 @@ async fn weak_password_is_rejected_before_token_consumption() {
async fn reset_mail_only_goes_to_known_accounts() {
let (server, _pool, mut mail) = app().await;
// Nothing was registered yet, so no background task can deliver
// anything: if mail shows up within 300 ms the endpoint leaked.
request_reset(&server, "ghost@example.com").await;
assert!(
mail.try_recv().is_err(),
"unknown account must not enqueue a reset email"
);
assert_no_mail(&mut mail).await;
let (_, email) = common::register_account(&server).await;
request_reset(&server, &email).await;
let sent = mail.recv().await.expect("mail");
let sent = wait_reset_mail(&mut mail).await;
assert_eq!(sent.to, email);
assert!(sent.token.starts_with("lvpr_"));
}

View file

@ -3,3 +3,18 @@
Addon marketplace backend: versions, publishing, moderation (Подсистема 3).
See `TODO.md` (Фаза 10, "Подсистема 3") and `backend/STRUCTURE.md`. No
implementation plan yet.
## Publish gate (decided, applies when this service is built)
Publishing addons is allowed only for accounts with a **verified email**.
There is no `can_publish_addons` HTTP endpoint to call: the check lives in
the caller, and the data comes from accounts-service:
- `AuthenticateDeviceReply.email_verified` (gRPC, `common/proto/accounts.proto`)
— returned alongside `account_id` since the 0006 migration. For
site-session flows (publishing from the site), call the accounts-service
`/me`-equivalent or extend the internal gRPC with an account lookup; do
not re-derive verification state locally.
- Rule: `email_verified == false` → publish endpoints answer `403 Forbidden`
with `email not verified`, mirroring `/device/confirm`.

View file

@ -6,6 +6,9 @@ package accounts.v1;
service AccountsInternal {
// Resolves a mod's long-lived device token to its account and bumps
// device_links.last_seen. UNAUTHENTICATED if the token is unknown/revoked.
// email_verified lets capability-gating callers (addons-registry publish,
// future mod features) refuse service to unconfirmed addresses without a
// second lookup.
rpc AuthenticateDevice(AuthenticateDeviceRequest) returns (AuthenticateDeviceReply);
// Nick + avatar for showcase authors etc. Never returns email or role.
@ -13,7 +16,10 @@ service AccountsInternal {
}
message AuthenticateDeviceRequest { string device_token = 1; }
message AuthenticateDeviceReply { string account_id = 1; }
message AuthenticateDeviceReply {
string account_id = 1;
bool email_verified = 2;
}
message GetPublicProfilesRequest { repeated string account_ids = 1; }
message PublicProfile {

View file

@ -78,6 +78,17 @@ services:
AVATAR_PUBLIC_BASE_URL: https://visual.loki-code.dev/api/media
PORT: 8081
GRPC_PORT: 50051
# Outgoing mail (Resend SMTP): secrets live in .env on the VPS, never
# in the repo. Empty SMTP_HOST keeps mail disabled (endpoints answer
# 503, fail-closed).
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
MAIL_FROM: ${MAIL_FROM:-}
# Link origin inside emails; must match the public site origin.
PUBLIC_BASE_URL: https://visual.loki-code.dev
MAIL_LANG: ${MAIL_LANG:-ru}
networks: [lovisual-internal]
configs-service:

View file

@ -60,6 +60,22 @@ pub fn rules() -> Vec<Rule> {
Quota::per_minute(n(10)),
Ip,
),
// Verification tokens are 256-bit random, so this limit exists to
// keep the route cheap, not to stop brute force.
rule(
Method::POST,
"/auth/verify-email",
Quota::per_minute(n(10)),
Ip,
),
// Requires login: per account (IP until the credential resolves),
// tight enough that one session cannot spam the mailbox.
rule(
Method::POST,
"/auth/resend-verification",
Quota::per_hour(n(3)),
Account,
),
rule(Method::POST, "/auth/refresh", Quota::per_minute(n(30)), Ip),
rule(Method::POST, "/device/code", Quota::per_minute(n(10)), Ip),
// The mod polls every 2–3 s for up to 10 min: 10/min would break linking.