Lets operators run a closed-membership deployment by setting
ALLOW_SELF_REGISTER=false (default true, so existing deploys are
unaffected). When off, POST /auth/register returns 403 forbidden. The
rate-limit token is consumed BEFORE the disabled check so the timing
doesn't distinguish enabled-but-rejected from disabled — closes the
toggle-state probe channel.
New public GET /auth/config returns { self_register_enabled: bool }
so the frontend can render its register affordances correctly
without conflating "disabled" with "rate-limited" (which a probe
attempt would).
Frontend: a lightweight reactive `authConfig` store loads the flag
once on root-layout mount (and again on /register direct navigation,
which bypasses the layout's onMount). Header hides the Register link
when the toggle is off; /register renders a "self-registration is
disabled — ask an administrator" notice instead of the form.
Admin-create endpoint that pairs with this toggle is intentionally
not in this PR — it lands as the next branch (feat/admin-user-create).
The toggle alone is independently useful for deployments that want
to lock down enrollment without yet wiring an admin UI.
809 lines
26 KiB
Rust
809 lines
26 KiB
Rust
mod common;
|
|
|
|
use axum::http::{header, StatusCode};
|
|
use serde_json::json;
|
|
use sqlx::PgPool;
|
|
use tower::ServiceExt;
|
|
|
|
fn creds(username: &str) -> serde_json::Value {
|
|
json!({ "username": username, "password": "hunter2hunter2" })
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn register_creates_user_and_sets_session_cookie(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/register",
|
|
creds("alice"),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
|
|
let cookie_header = resp
|
|
.headers()
|
|
.get(header::SET_COOKIE)
|
|
.expect("Set-Cookie present")
|
|
.to_str()
|
|
.unwrap()
|
|
.to_string();
|
|
assert!(cookie_header.starts_with("mangalord_session="));
|
|
assert!(cookie_header.contains("HttpOnly"));
|
|
assert!(cookie_header.contains("SameSite=Lax"));
|
|
assert!(cookie_header.contains("Path=/"));
|
|
// In the test harness cookie_secure is false; production has Secure.
|
|
assert!(!cookie_header.contains("Secure"));
|
|
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["user"]["username"], "alice");
|
|
assert!(body["user"]["id"].as_str().is_some());
|
|
assert!(
|
|
body["user"].get("password_hash").is_none(),
|
|
"password_hash must never leak to the API"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn register_rejects_duplicate_username_with_conflict(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let _ = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CONFLICT);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "conflict");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn register_rejects_case_only_username_collisions(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let _ = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
// Mixed-case variant collides via the lower(username) index.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("Alice")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CONFLICT);
|
|
|
|
// Login with either casing finds the same user.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json("/api/v1/auth/login", creds("ALICE")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn register_rejects_short_password(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/register",
|
|
json!({ "username": "alice", "password": "short" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "invalid_input");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn login_succeeds_and_rotates_session(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let register_resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
let register_cookie = common::extract_session_cookie(®ister_resp)
|
|
.expect("register sets a cookie");
|
|
|
|
let login_resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/login", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(login_resp.status(), StatusCode::OK);
|
|
let login_cookie =
|
|
common::extract_session_cookie(&login_resp).expect("login sets a cookie");
|
|
assert!(login_cookie.starts_with("mangalord_session="));
|
|
|
|
// Login must mint a *new* session, not echo the registration one.
|
|
assert_ne!(
|
|
register_cookie, login_cookie,
|
|
"login should rotate the session token; got the register cookie back"
|
|
);
|
|
|
|
// The registration cookie is still valid until it expires naturally —
|
|
// that's the documented behaviour, asserted here so a regression that
|
|
// invalidates other devices' sessions on login would be noisy.
|
|
let me_resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", ®ister_cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(me_resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn login_rejects_wrong_password(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let _ = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": "alice", "password": "wrongpassword" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "unauthenticated");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn login_rejects_unknown_user(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json("/api/v1/auth/login", creds("ghost")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn me_returns_user_with_valid_cookie(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (username, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["user"]["username"], username);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn me_returns_401_without_cookie(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get("/api/v1/auth/me"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn logout_clears_session(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/auth/logout",
|
|
json!({}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
|
|
|
// Same cookie no longer works.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn change_password_rotates_sessions_and_swaps_credentials(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (username, cookie) = common::register_user(&h.app).await;
|
|
|
|
// Log in a second time to seed a "second device" session that
|
|
// should also be invalidated by the password change.
|
|
let second_resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": username, "password": "hunter2hunter2" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let second_cookie = common::extract_session_cookie(&second_resp).unwrap();
|
|
assert_ne!(cookie, second_cookie);
|
|
|
|
// Change the password.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::patch_json_with_cookie(
|
|
"/api/v1/auth/me/password",
|
|
json!({
|
|
"current_password": "hunter2hunter2",
|
|
"new_password": "freshpassfreshpass"
|
|
}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
|
let rotated_cookie =
|
|
common::extract_session_cookie(&resp).expect("password change must mint a new cookie");
|
|
assert_ne!(cookie, rotated_cookie, "session must rotate");
|
|
|
|
// Both original cookies are dead (other devices signed out).
|
|
for stale in [&cookie, &second_cookie] {
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", stale))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"stale cookie {stale} should be invalid"
|
|
);
|
|
}
|
|
|
|
// The rotated cookie is live.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", &rotated_cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// Old password no longer logs in.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": username, "password": "hunter2hunter2" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
// New password does.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": username, "password": "freshpassfreshpass" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn change_password_via_bearer_leaves_bearer_working(pool: PgPool) {
|
|
// Bot scripts that call PATCH /me/password using Authorization:
|
|
// Bearer must keep their bearer working — change_password only
|
|
// wipes session rows, not api_tokens. Pin this behaviour so a
|
|
// future refactor that wipes everything would fail noisily.
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/auth/tokens",
|
|
json!({ "name": "ci-bot" }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let bearer = common::body_json(resp).await["bearer"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
|
|
// Use the bearer to change the password.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot({
|
|
let body = json!({
|
|
"current_password": "hunter2hunter2",
|
|
"new_password": "freshpassfreshpass"
|
|
});
|
|
axum::http::Request::builder()
|
|
.method("PATCH")
|
|
.uri("/api/v1/auth/me/password")
|
|
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
|
.header(axum::http::header::AUTHORIZATION, format!("Bearer {bearer}"))
|
|
.body(axum::body::Body::from(body.to_string()))
|
|
.unwrap()
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
|
|
|
// Cookie is dead (all sessions wiped).
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
// Bearer still works — that's the documented contract.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_bearer("/api/v1/auth/me", &bearer))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn change_password_rejects_wrong_current_with_401(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::patch_json_with_cookie(
|
|
"/api/v1/auth/me/password",
|
|
json!({
|
|
"current_password": "definitelyNotIt",
|
|
"new_password": "freshpassfreshpass"
|
|
}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "unauthenticated");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn change_password_rejects_weak_new_password(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::patch_json_with_cookie(
|
|
"/api/v1/auth/me/password",
|
|
json!({
|
|
"current_password": "hunter2hunter2",
|
|
"new_password": "short"
|
|
}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "invalid_input");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn change_password_requires_authentication(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::patch_json(
|
|
"/api/v1/auth/me/password",
|
|
json!({
|
|
"current_password": "hunter2hunter2",
|
|
"new_password": "freshpassfreshpass"
|
|
}),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn me_rejects_expired_session(pool: PgPool) {
|
|
use chrono::{Duration, Utc};
|
|
use mangalord::auth::token::generate_token;
|
|
|
|
let h = common::harness(pool.clone());
|
|
common::register_user(&h.app).await;
|
|
|
|
// Grab the user that was just registered so we can hand-craft an
|
|
// expired session for them.
|
|
let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users LIMIT 1")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let (raw, hash) = generate_token();
|
|
let expires_at = Utc::now() - Duration::hours(1);
|
|
sqlx::query(
|
|
"INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
|
|
)
|
|
.bind(user_id)
|
|
.bind(&hash[..])
|
|
.bind(expires_at)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let cookie = format!("mangalord_session={raw}");
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "unauthenticated");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_and_use_bot_token(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/auth/tokens",
|
|
json!({ "name": "ci-bot" }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["name"], "ci-bot");
|
|
let bearer = body["bearer"]
|
|
.as_str()
|
|
.expect("raw bearer in response")
|
|
.to_string();
|
|
// `token_hash` is `#[serde(skip)]` on `ApiToken`, so it must be
|
|
// *absent* from the JSON. `is_null()` would also accept a
|
|
// `"token_hash": null` payload, which we don't want — use
|
|
// `get(...).is_none()` for the stronger assertion.
|
|
assert!(
|
|
body.get("token_hash").is_none(),
|
|
"token_hash must not appear in the response at all"
|
|
);
|
|
|
|
// Use the bearer to hit /me — should authenticate.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_bearer("/api/v1/auth/me", &bearer))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn user_a_cannot_delete_user_b_token(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie_a) = common::register_user(&h.app).await;
|
|
let (_, cookie_b) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/auth/tokens",
|
|
json!({ "name": "alice-bot" }),
|
|
&cookie_a,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let body = common::body_json(resp).await;
|
|
let token_id = body["id"].as_str().unwrap().to_string();
|
|
|
|
// User B attempts to delete user A's token → 403.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::delete_with_cookie(
|
|
&format!("/api/v1/auth/tokens/{token_id}"),
|
|
&cookie_b,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "forbidden");
|
|
|
|
// User A succeeds.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::delete_with_cookie(
|
|
&format!("/api/v1/auth/tokens/{token_id}"),
|
|
&cookie_a,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
|
}
|
|
|
|
/// Username enumeration via login response time: an attacker probes
|
|
/// for valid usernames by measuring how long /auth/login takes. Before
|
|
/// the equalisation fix, the no-user branch returned 401 in <1 ms
|
|
/// while the wrong-password branch took ~50-100 ms (the argon2 verify
|
|
/// cost). This test asserts the no-user branch now spends at least
|
|
/// some meaningful fraction of the wrong-password branch's time.
|
|
///
|
|
/// Tolerance is intentionally loose so CI variance doesn't flap the
|
|
/// test. The unequalised gap is large enough (~50x) that even a noisy
|
|
/// CI run with a 5x slack still catches it.
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn login_no_user_branch_runs_argon2_for_timing_equalisation(pool: PgPool) {
|
|
use std::time::Instant;
|
|
|
|
let h = common::harness(pool);
|
|
|
|
// Register the victim user so the wrong-password branch has a real
|
|
// argon2 hash to verify against.
|
|
let _ = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/register",
|
|
json!({ "username": "victim", "password": "hunter2hunter2" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Warm-up: first login of the process initialises the dummy hash
|
|
// lazily. Skip that cost when measuring.
|
|
let _ = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": "victim", "password": "wrong" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let _ = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": "ghost", "password": "wrong" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Median-of-N is more stable than a single sample.
|
|
async fn sample_min(
|
|
app: &axum::Router,
|
|
username: &str,
|
|
n: u32,
|
|
) -> std::time::Duration {
|
|
let mut samples = Vec::with_capacity(n as usize);
|
|
for _ in 0..n {
|
|
let req = common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": username, "password": "wrong-guess" }),
|
|
);
|
|
let t = Instant::now();
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let d = t.elapsed();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
samples.push(d);
|
|
}
|
|
// Use the minimum: it's the floor that argon2 takes, robust
|
|
// against unrelated stalls (DB connection acquisition, etc.).
|
|
*samples.iter().min().unwrap()
|
|
}
|
|
|
|
let wrong_pwd = sample_min(&h.app, "victim", 3).await;
|
|
let no_user = sample_min(&h.app, "ghost", 3).await;
|
|
|
|
// 5x slack: argon2 dominates both branches, so they should be
|
|
// within an order of magnitude. Unequalised, no_user would be
|
|
// ~50-100x faster. Asserting "no_user >= wrong_pwd / 5" catches
|
|
// the bug without being flaky in CI.
|
|
assert!(
|
|
no_user * 5 >= wrong_pwd,
|
|
"login timing leaks user existence: no_user={no_user:?}, wrong_pwd={wrong_pwd:?}"
|
|
);
|
|
}
|
|
|
|
/// Brute-force / spray protection: at default production limits, a
|
|
/// tight loop of /auth/login attempts should burst through the bucket
|
|
/// and then 429 every subsequent request until the bucket refills.
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn login_rate_limited_under_burst_pressure(pool: PgPool) {
|
|
let h = common::harness_with_auth_rate_limit(pool, 1, 3);
|
|
|
|
// Register a victim so the wrong-password branch is real work.
|
|
let _ = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("victim")))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Register consumed one token from the burst-3 bucket. Fire 30
|
|
// wrong-password logins back-to-back; with per_sec=1 the refill
|
|
// is too slow to keep up and at least one must come back 429.
|
|
let mut saw_429 = false;
|
|
for _ in 0..30 {
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": "victim", "password": "wrong" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
if resp.status() == StatusCode::TOO_MANY_REQUESTS {
|
|
// RFC 6585 §4: 429 SHOULD include a Retry-After header. The
|
|
// value is in seconds; with per_sec=1 the bucket needs ~1s
|
|
// to refill, so the header should be 1 or 2.
|
|
let retry_after = resp
|
|
.headers()
|
|
.get(axum::http::header::RETRY_AFTER)
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.parse::<u32>().ok())
|
|
.expect("Retry-After header present and numeric");
|
|
assert!(
|
|
retry_after >= 1,
|
|
"Retry-After must be at least 1s, got {retry_after}"
|
|
);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "too_many_requests");
|
|
saw_429 = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
saw_429,
|
|
"expected at least one 429 within 30 rapid login attempts"
|
|
);
|
|
}
|
|
|
|
/// Default (test-harness) limits are disabled, so existing tests that
|
|
/// fire multiple auth requests don't start failing.
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn default_test_harness_does_not_rate_limit(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
for i in 0..50 {
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": format!("nobody-{i}"), "password": "x" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
// None of these should be 429 — only 401.
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "iter {i}");
|
|
}
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn delete_unknown_token_is_404(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::delete_with_cookie(
|
|
"/api/v1/auth/tokens/00000000-0000-0000-0000-000000000000",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
/// Bot token names are user-supplied free-form strings; a 10 MB name
|
|
/// was accepted before. Cap at 64 chars to match the other free-form
|
|
/// identifier caps (tags, collection names). The response uses
|
|
/// `ValidationFailed` (422 with per-field details) so clients can
|
|
/// render the same shape they already handle for `attach_tag`.
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_token_rejects_name_over_64_chars(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/auth/tokens",
|
|
json!({ "name": "x".repeat(65) }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "validation_failed");
|
|
assert!(body["error"]["details"]["name"].is_string());
|
|
}
|
|
|
|
// ---- self-register toggle + /auth/config -----------------------------------
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn auth_config_reports_self_register_enabled_by_default(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get("/api/v1/auth/config"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["self_register_enabled"], true);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn auth_config_reflects_self_register_disabled(pool: PgPool) {
|
|
let h = common::harness_with_self_register_disabled(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get("/api/v1/auth/config"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["self_register_enabled"], false);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn register_returns_403_when_self_register_disabled(pool: PgPool) {
|
|
let h = common::harness_with_self_register_disabled(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "forbidden");
|
|
}
|