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>
This commit is contained in:
@@ -233,6 +233,153 @@ async fn logout_clears_session(pool: PgPool) {
|
||||
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};
|
||||
|
||||
Reference in New Issue
Block a user