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
|
|
@ -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_"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue