Files
Mangalord/backend/tests/api_auth.rs
MechaCat02 69eca21fb5 feat: password change endpoint with full session rotation
Adds the pre-1.0 password-change story flagged by the audit. Browser
users and bot owners both go through PATCH /api/v1/auth/me/password
with the current + new password in the body.

Implementation in `api::auth::change_password`:
- CurrentUser-gated: 401 if unauthenticated.
- Verifies current_password against the stored argon2 hash. Wrong
  current → 401 unauthenticated, matching the login contract.
- new_password runs through the same `validate_password` used at
  registration (≥8 chars). Weak → 400 invalid_input.
- On success, wraps the swap in a single transaction:
  - UPDATE users.password_hash with a fresh argon2 hash.
  - DELETE every session for this user (signs out other devices —
    any cookie stolen before the change is dead now).
  - INSERT a new session and mint a fresh cookie so the caller stays
    logged in.
- 204 + Set-Cookie on success.

Bot tokens (api_tokens) are intentionally left alone. They're explicit
opt-in credentials that the user can already audit and revoke
individually via DELETE /auth/tokens/{id}; rotating them on every
password change would surprise CI scripts.

Repo refactor: `repo::session::create` accepts `impl PgExecutor<'_>`
(same pattern feat/uploads used for chapters), and a new
`session::delete_all_for_user` covers the "sign out everywhere"
write. The existing `delete_by_token_hash` (used by logout) is
unchanged.

Coverage in tests/api_auth.rs (4 cases):
- change_password_rotates_sessions_and_swaps_credentials — happy path
  asserts the new cookie differs from the original, that both the
  original cookie AND a second-device cookie become invalid, that the
  new cookie keeps working, that login with the old password fails
  (401) and login with the new password succeeds.
- change_password_rejects_wrong_current_with_401 — wrong current
  password returns 401 unauthenticated.
- change_password_rejects_weak_new_password — new_password "short"
  returns 400 invalid_input.
- change_password_requires_authentication — no cookie returns 401.

README updated with the new endpoint in the auth table.

Lockstep version bump to 0.10.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:52:54 +02:00

521 lines
16 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(&register_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", &register_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_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);
}
#[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);
}