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:
parent
12ce6216b6
commit
c57f851a8b
48 changed files with 1810 additions and 120 deletions
|
|
@ -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
54
backend/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
33
backend/accounts-service/migrations/0006_email_tokens.sql
Normal file
33
backend/accounts-service/migrations/0006_email_tokens.sql
Normal 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;
|
||||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ pub mod handlers;
|
|||
pub mod password;
|
||||
pub mod reset;
|
||||
pub mod tokens;
|
||||
pub mod verify;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
95
backend/accounts-service/src/auth/verify/handlers.rs
Normal file
95
backend/accounts-service/src/auth/verify/handlers.rs
Normal 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(())
|
||||
}
|
||||
50
backend/accounts-service/src/auth/verify/mod.rs
Normal file
50
backend/accounts-service/src/auth/verify/mod.rs
Normal 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))));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
177
backend/accounts-service/src/mail/mod.rs
Normal file
177
backend/accounts-service/src/mail/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
170
backend/accounts-service/src/mail/templates.rs
Normal file
170
backend/accounts-service/src/mail/templates.rs
Normal 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("&"),
|
||||
'<' => 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(
|
||||
"<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("<script>"));
|
||||
}
|
||||
|
||||
#[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="));
|
||||
}
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
212
backend/accounts-service/tests/email_verify.rs
Normal file
212
backend/accounts-service/tests/email_verify.rs
Normal 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();
|
||||
}
|
||||
|
|
@ -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_"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue