fix: CI workflow, password reset flow, health/limits in services, session refactor, dead mixin stub cleanup
This commit is contained in:
parent
45dd592c40
commit
f4e15b45c9
95 changed files with 899 additions and 185 deletions
|
|
@ -9,10 +9,10 @@ path = "src/lib.rs"
|
|||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["multipart", "macros"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||
tower-http = { version = "0.7", features = ["trace", "cors"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "macros", "migrate"] }
|
||||
|
|
@ -36,4 +36,5 @@ tonic = "0.14"
|
|||
|
||||
[dev-dependencies]
|
||||
axum-test = "21"
|
||||
aws-sdk-s3 = "1"
|
||||
tokio-stream = { version = "0.1", features = ["net"] }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
-- Single-use password reset tokens (stored hashed, like refresh tokens).
|
||||
-- A token is valid for 30 minutes; issuing a new request for the same
|
||||
-- account supersedes any token still pending from an earlier request.
|
||||
CREATE TABLE password_reset_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_password_reset_tokens_account_id ON password_reset_tokens(account_id);
|
||||
|
|
@ -24,6 +24,17 @@ pub fn validate_register(
|
|||
password: &str,
|
||||
nick: &str,
|
||||
) -> Result<(String, String), AppError> {
|
||||
let email = normalize_email(email)?;
|
||||
let nick = validate_nick(nick)?;
|
||||
validate_password(password)?;
|
||||
Ok((email, nick))
|
||||
}
|
||||
|
||||
/// Trims and shape-checks an email address the same way for every auth flow
|
||||
/// 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.
|
||||
pub fn normalize_email(email: &str) -> Result<String, AppError> {
|
||||
let email = email.trim();
|
||||
if email.is_empty() {
|
||||
return Err(AppError::Validation("email must not be empty".into()));
|
||||
|
|
@ -42,7 +53,10 @@ pub fn validate_register(
|
|||
"email must have exactly one '@' with non-empty parts".into(),
|
||||
));
|
||||
}
|
||||
Ok(email.to_string())
|
||||
}
|
||||
|
||||
fn validate_nick(nick: &str) -> Result<String, AppError> {
|
||||
let nick = nick.trim();
|
||||
let nick_len = nick.chars().count();
|
||||
if nick_len == 0 || nick_len > 32 {
|
||||
|
|
@ -55,7 +69,11 @@ pub fn validate_register(
|
|||
"nick must not contain control characters".into(),
|
||||
));
|
||||
}
|
||||
Ok(nick.to_string())
|
||||
}
|
||||
|
||||
/// Password policy shared by registration and password reset.
|
||||
pub fn validate_password(password: &str) -> Result<(), AppError> {
|
||||
if password.chars().count() < 8 {
|
||||
return Err(AppError::Validation(
|
||||
"password must be at least 8 characters".into(),
|
||||
|
|
@ -66,8 +84,7 @@ pub fn validate_register(
|
|||
"password must be at most {MAX_PASSWORD_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((email.to_string(), nick.to_string()))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::accounts::model::validate_register;
|
||||
use crate::accounts::repo;
|
||||
use crate::auth::{password, tokens};
|
||||
use crate::auth::{password, reset, tokens};
|
||||
use crate::error::{AppError, AppJson};
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
|
@ -13,6 +13,9 @@ 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.
|
||||
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
|
||||
// ~100ms as "wrong password" — otherwise response time leaks which
|
||||
|
|
@ -22,6 +25,15 @@ 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)
|
||||
}
|
||||
|
||||
pub fn with_mail(
|
||||
pool: sqlx::PgPool,
|
||||
jwt_secret: String,
|
||||
cookie_secure: bool,
|
||||
mail: reset::MailBox,
|
||||
) -> Self {
|
||||
// Hashing a fixed, short constant with fixed valid params cannot
|
||||
// fail; this is not user input, so the expect is a startup invariant.
|
||||
let dummy_hash = password::hash_password("timing-equalizer-not-a-real-password")
|
||||
|
|
@ -31,6 +43,7 @@ impl AuthState {
|
|||
jwt_secret,
|
||||
cookie_secure,
|
||||
hasher: password::PasswordHasher::new(),
|
||||
mail,
|
||||
dummy_hash,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod handlers;
|
||||
pub mod password;
|
||||
pub mod reset;
|
||||
pub mod tokens;
|
||||
|
|
|
|||
86
backend/accounts-service/src/auth/reset/handlers.rs
Normal file
86
backend/accounts-service/src/auth/reset/handlers.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use crate::accounts::{model, repo};
|
||||
use crate::auth::reset;
|
||||
use crate::error::{AppError, AppJson};
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::handlers::AuthState;
|
||||
#[derive(Deserialize)]
|
||||
pub struct ForgotPasswordRequest {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AcceptedResponse {
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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);
|
||||
}
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(AcceptedResponse {
|
||||
status: "reset email sent if the account exists",
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetPasswordRequest {
|
||||
pub token: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// POST /auth/reset-password { token, new_password }
|
||||
///
|
||||
/// Consumes the token atomically, rewrites the password hash and revokes
|
||||
/// every refresh session of the account. A bad token is a plain 400 with no
|
||||
/// distinction between unknown, expired and already-used — all three are the
|
||||
/// same "try again" situation from an attacker's point of view.
|
||||
pub async fn reset_password(
|
||||
State(state): State<AuthState>,
|
||||
AppJson(req): AppJson<ResetPasswordRequest>,
|
||||
) -> Result<Json<AcceptedResponse>, AppError> {
|
||||
if !reset::is_well_formed_token(&req.token) {
|
||||
return Err(AppError::Validation("malformed reset token".into()));
|
||||
}
|
||||
model::validate_password(&req.new_password)?;
|
||||
|
||||
let account_id = reset::consume_reset_token(&state.pool, &req.token)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Validation("reset token is invalid or expired".into()))?;
|
||||
|
||||
let hash = state
|
||||
.hasher
|
||||
.hash(req.new_password)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
let updated = sqlx::query("UPDATE accounts SET password_hash = $2 WHERE id = $1")
|
||||
.bind(account_id)
|
||||
.bind(&hash)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
if updated.rows_affected() != 1 {
|
||||
// The token row referenced a cascade-deleted account.
|
||||
return Err(AppError::Validation(
|
||||
"reset token is invalid or expired".into(),
|
||||
));
|
||||
}
|
||||
reset::revoke_all_sessions(&state.pool, account_id).await?;
|
||||
Ok(Json(AcceptedResponse {
|
||||
status: "password updated",
|
||||
}))
|
||||
}
|
||||
129
backend/accounts-service/src/auth/reset/mod.rs
Normal file
129
backend/accounts-service/src/auth/reset/mod.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
pub mod handlers;
|
||||
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset links must be used quickly: long windows turn a leaked email into
|
||||
/// an account takeover. 30 minutes is the common industry compromise.
|
||||
const TTL_MINUTES: i64 = 30;
|
||||
|
||||
pub const TOKEN_PREFIX: &str = "lvpr_";
|
||||
|
||||
/// A reset token is base64url like every other opaque token in this service
|
||||
/// ("lvpr_" prefix + 43 chars), so the length check alone filters out most
|
||||
/// junk before the database is ever touched.
|
||||
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.
|
||||
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",
|
||||
)
|
||||
.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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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))
|
||||
}
|
||||
|
||||
/// Kill every refresh session of the account: whoever holds a stolen session
|
||||
/// cookie must not survive a password change.
|
||||
pub async fn revoke_all_sessions(pool: &PgPool, account_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE refresh_tokens SET revoked_at = now()
|
||||
WHERE account_id = $1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(account_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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("lvpr_short"));
|
||||
assert!(!is_well_formed_token(
|
||||
"XWpr_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -16,9 +16,21 @@ use axum::{
|
|||
};
|
||||
use config::Config;
|
||||
use device::{handlers::DeviceState, store::DeviceStore};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
|
||||
let auth_state = AuthState::new(pool.clone(), cfg.jwt_secret.clone(), cfg.cookie_secure);
|
||||
build_app_with_mail(pool, cfg, auth::reset::MailBox::Log)
|
||||
}
|
||||
|
||||
/// Same router with a custom reset-email back-end: production uses the log
|
||||
/// mailer, tests capture tokens through the queue variant.
|
||||
pub fn build_app_with_mail(pool: sqlx::PgPool, cfg: &Config, mail: auth::reset::MailBox) -> Router {
|
||||
let auth_state = AuthState::with_mail(
|
||||
pool.clone(),
|
||||
cfg.jwt_secret.clone(),
|
||||
cfg.cookie_secure,
|
||||
mail,
|
||||
);
|
||||
let device_state = DeviceState {
|
||||
store: DeviceStore::default(),
|
||||
pool: pool.clone(),
|
||||
|
|
@ -43,6 +55,14 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
|
|||
.route("/auth/login", post(auth::handlers::login))
|
||||
.route("/auth/refresh", post(auth::handlers::refresh))
|
||||
.route("/auth/logout", post(auth::handlers::logout))
|
||||
.route(
|
||||
"/auth/forgot-password",
|
||||
post(auth::reset::handlers::forgot_password),
|
||||
)
|
||||
.route(
|
||||
"/auth/reset-password",
|
||||
post(auth::reset::handlers::reset_password),
|
||||
)
|
||||
.with_state(auth_state);
|
||||
|
||||
let device_routes = Router::new()
|
||||
|
|
@ -87,4 +107,5 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
|
|||
Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.merge(api)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ use accounts_service::{build_app, config::Config};
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
dotenvy::dotenv().ok();
|
||||
let cfg = Config::from_env()?;
|
||||
cfg.validate()?;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ fn form(bytes: Vec<u8>) -> MultipartForm {
|
|||
|
||||
#[tokio::test]
|
||||
async fn valid_upload_stores_avatar_and_returns_url() {
|
||||
common::init_test_logging();
|
||||
common::ensure_avatar_bucket().await;
|
||||
let pool = common::test_pool().await;
|
||||
let server = common::test_server(accounts_service::build_app(
|
||||
pool.clone(),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ use uuid::Uuid;
|
|||
// mounts as `mod common;`).
|
||||
pub use ::common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER};
|
||||
|
||||
pub fn init_test_logging() {
|
||||
let _ = tracing_subscriber::fmt::try_init();
|
||||
}
|
||||
|
||||
pub async fn test_pool() -> sqlx::PgPool {
|
||||
let url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
|
||||
|
|
@ -60,3 +64,65 @@ pub async fn register_account(server: &TestServer) -> (Uuid, String) {
|
|||
email,
|
||||
)
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// Retries briefly so a just-started container does not race the test.
|
||||
pub async fn ensure_avatar_bucket() {
|
||||
let creds =
|
||||
aws_sdk_s3::config::Credentials::new("minioadmin", "minioadmin", None, None, "static");
|
||||
let config = aws_sdk_s3::config::Builder::new()
|
||||
.endpoint_url("http://localhost:9000")
|
||||
.credentials_provider(creds)
|
||||
.region(aws_sdk_s3::config::Region::new("us-east-1"))
|
||||
.force_path_style(true)
|
||||
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
||||
.build();
|
||||
let client = aws_sdk_s3::Client::from_conf(config);
|
||||
let mut last_err = String::new();
|
||||
for _ in 0..30 {
|
||||
// Probe writability, not just bucket existence: a freshly started
|
||||
// S3 backend may still be electing volumes ("Not enough data nodes"
|
||||
// on SeaweedFS) right after create_bucket succeeds.
|
||||
match async {
|
||||
// Already-exists is success (MinIO: BucketAlreadyOwnedByYou,
|
||||
// SeaweedFS: BucketAlreadyExists); anything else aborts.
|
||||
if let Err(e) = client
|
||||
.create_bucket()
|
||||
.bucket("lovisual-avatars-test")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let msg = format!("{e:?}");
|
||||
if !msg.contains("BucketAlready") {
|
||||
return Err(msg);
|
||||
}
|
||||
}
|
||||
client
|
||||
.put_object()
|
||||
.bucket("lovisual-avatars-test")
|
||||
.key(".probe")
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"probe"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let _ = client
|
||||
.delete_object()
|
||||
.bucket("lovisual-avatars-test")
|
||||
.key(".probe")
|
||||
.send()
|
||||
.await;
|
||||
Ok::<(), String>(())
|
||||
}
|
||||
.await
|
||||
{
|
||||
Ok(_) => return,
|
||||
Err(msg) => {
|
||||
last_err = msg;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("S3 backend is not writable: {last_err}");
|
||||
}
|
||||
|
|
|
|||
215
backend/accounts-service/tests/password_reset.rs
Normal file
215
backend/accounts-service/tests/password_reset.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
mod common;
|
||||
|
||||
use accounts_service::auth::reset::{Mail, MailBox};
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sqlx::PgPool;
|
||||
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)
|
||||
}
|
||||
|
||||
async fn request_reset(server: &TestServer, email: &str) {
|
||||
server
|
||||
.post("/auth/forgot-password")
|
||||
.json(&serde_json::json!({ "email": email }))
|
||||
.await
|
||||
.assert_status(StatusCode::ACCEPTED);
|
||||
}
|
||||
|
||||
async fn reset_with(server: &TestServer, token: &str, password: &str) -> axum_test::TestResponse {
|
||||
server
|
||||
.post("/auth/reset-password")
|
||||
.json(&serde_json::json!({ "token": token, "new_password": password }))
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forgot_password_never_reveals_account_existence() {
|
||||
let (server, _pool, _mail) = app().await;
|
||||
|
||||
// Unknown email and known email must be indistinguishable: same status,
|
||||
// same body. Enumeration is the first step of account takeover.
|
||||
let unknown = server
|
||||
.post("/auth/forgot-password")
|
||||
.json(&serde_json::json!({ "email": "nobody-here@example.com" }))
|
||||
.await;
|
||||
unknown.assert_status(StatusCode::ACCEPTED);
|
||||
let unknown_body: serde_json::Value = unknown.json();
|
||||
assert_eq!(
|
||||
unknown_body["status"],
|
||||
"reset email sent if the account exists"
|
||||
);
|
||||
|
||||
let (_, email) = common::register_account(&server).await;
|
||||
let known = server
|
||||
.post("/auth/forgot-password")
|
||||
.json(&serde_json::json!({ "email": email }))
|
||||
.await;
|
||||
known.assert_status(StatusCode::ACCEPTED);
|
||||
let known_body: serde_json::Value = known.json();
|
||||
assert_eq!(unknown_body, known_body);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_reset_flow_changes_password_and_kills_sessions() {
|
||||
let (server, _pool, mut mail) = app().await;
|
||||
let (_, email) = common::register_account(&server).await;
|
||||
let old_password = "correct-horse-battery-staple";
|
||||
|
||||
// Log in to create a live refresh session the reset must kill.
|
||||
let login = server
|
||||
.post("/auth/login")
|
||||
.json(&serde_json::json!({ "email": email, "password": old_password }))
|
||||
.await;
|
||||
login.assert_status_ok();
|
||||
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;
|
||||
|
||||
reset_with(&server, &token, "brand-new-password-1")
|
||||
.await
|
||||
.assert_status_ok();
|
||||
|
||||
// Old password is dead, new password works.
|
||||
server
|
||||
.post("/auth/login")
|
||||
.json(&serde_json::json!({ "email": email, "password": old_password }))
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
server
|
||||
.post("/auth/login")
|
||||
.json(&serde_json::json!({ "email": email, "password": "brand-new-password-1" }))
|
||||
.await
|
||||
.assert_status_ok();
|
||||
|
||||
// Every refresh session issued before the reset is revoked: replaying
|
||||
// the pre-reset cookie must not survive (a stolen session cannot
|
||||
// outlive a password change).
|
||||
let replay = server
|
||||
.post("/auth/refresh")
|
||||
.add_cookie(old_refresh.as_str().into())
|
||||
.await;
|
||||
replay.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_token_is_single_use() {
|
||||
let (server, _pool, mut mail) = app().await;
|
||||
let (_, email) = common::register_account(&server).await;
|
||||
|
||||
request_reset(&server, &email).await;
|
||||
let token = mail.recv().await.expect("mail").token;
|
||||
|
||||
reset_with(&server, &token, "brand-new-password-1")
|
||||
.await
|
||||
.assert_status_ok();
|
||||
reset_with(&server, &token, "another-password-2")
|
||||
.await
|
||||
.assert_status(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_request_supersedes_pending_token() {
|
||||
let (server, _pool, mut mail) = app().await;
|
||||
let (_, email) = common::register_account(&server).await;
|
||||
|
||||
request_reset(&server, &email).await;
|
||||
let first = mail.recv().await.expect("mail").token;
|
||||
request_reset(&server, &email).await;
|
||||
let second = mail.recv().await.expect("mail").token;
|
||||
assert_ne!(first, second);
|
||||
|
||||
// The superseded token no longer works, the fresh one does.
|
||||
reset_with(&server, &first, "brand-new-password-1")
|
||||
.await
|
||||
.assert_status(StatusCode::BAD_REQUEST);
|
||||
reset_with(&server, &second, "brand-new-password-1")
|
||||
.await
|
||||
.assert_status_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_tokens_are_rejected_without_oracle() {
|
||||
let (server, _pool, _mail) = app().await;
|
||||
|
||||
server
|
||||
.post("/auth/reset-password")
|
||||
.json(&serde_json::json!({ "token": "short", "new_password": "brand-new-password-1" }))
|
||||
.await
|
||||
.assert_status(StatusCode::BAD_REQUEST);
|
||||
|
||||
// Unknown but well-formed token: same 400 family, and unlike the
|
||||
// shape-rejection above it must not leak which of unknown/expired/used
|
||||
// it is.
|
||||
let unknown = server
|
||||
.post("/auth/reset-password")
|
||||
.json(&serde_json::json!({
|
||||
"token": "lvpr_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"new_password": "brand-new-password-1"
|
||||
}))
|
||||
.await;
|
||||
unknown.assert_status(StatusCode::BAD_REQUEST);
|
||||
let body: serde_json::Value = unknown.json();
|
||||
assert_eq!(body["error"], "reset token is invalid or expired");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weak_password_is_rejected_before_token_consumption() {
|
||||
let (server, _pool, mut mail) = app().await;
|
||||
let (_, email) = common::register_account(&server).await;
|
||||
|
||||
request_reset(&server, &email).await;
|
||||
let token = mail.recv().await.expect("mail").token;
|
||||
|
||||
reset_with(&server, &token, "short12")
|
||||
.await
|
||||
.assert_status(StatusCode::BAD_REQUEST);
|
||||
|
||||
// The token must still be usable afterwards: rejecting a weak password
|
||||
// must not burn the user's one link.
|
||||
reset_with(&server, &token, "brand-new-password-1")
|
||||
.await
|
||||
.assert_status_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_mail_only_goes_to_known_accounts() {
|
||||
let (server, _pool, mut mail) = app().await;
|
||||
|
||||
request_reset(&server, "ghost@example.com").await;
|
||||
assert!(
|
||||
mail.try_recv().is_err(),
|
||||
"unknown account must not enqueue a reset email"
|
||||
);
|
||||
|
||||
let (_, email) = common::register_account(&server).await;
|
||||
request_reset(&server, &email).await;
|
||||
let sent = mail.recv().await.expect("mail");
|
||||
assert_eq!(sent.to, email);
|
||||
assert!(sent.token.starts_with("lvpr_"));
|
||||
}
|
||||
|
||||
/// The refresh cookie is httpOnly and scoped to /auth; axum-test exposes
|
||||
/// response headers, so parse Set-Cookie directly.
|
||||
fn refresh_cookie(res: &axum_test::TestResponse) -> Option<String> {
|
||||
res.headers()
|
||||
.get_all(axum::http::header::SET_COOKIE)
|
||||
.iter()
|
||||
.find_map(|v| {
|
||||
let s = v.to_str().ok()?;
|
||||
s.strip_prefix("lv_refresh=")
|
||||
.map(|rest| format!("lv_refresh={}", rest.split(';').next().unwrap_or("")))
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue