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:
MechaCat02
2026-05-16 23:52:53 +02:00
parent d81aca42a0
commit 69eca21fb5
7 changed files with 242 additions and 7 deletions

View File

@@ -7,7 +7,7 @@
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{delete, get, post};
use axum::routing::{delete, get, patch, post};
use axum::{Json, Router};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use chrono::{Duration, Utc};
@@ -29,6 +29,7 @@ pub fn routes() -> Router<AppState> {
.route("/auth/login", post(login))
.route("/auth/logout", post(logout))
.route("/auth/me", get(me))
.route("/auth/me/password", patch(change_password))
.route("/auth/tokens", post(create_token))
.route("/auth/tokens/:id", delete(delete_token))
}
@@ -49,6 +50,12 @@ pub struct CreateTokenInput {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct ChangePassword {
pub current_password: String,
pub new_password: String,
}
#[derive(Debug, Serialize)]
pub struct CreatedTokenResponse {
#[serde(flatten)]
@@ -111,6 +118,49 @@ async fn me(CurrentUser(user): CurrentUser) -> AppResult<Json<AuthResponse>> {
Ok(Json(AuthResponse { user }))
}
/// `PATCH /api/v1/auth/me/password` — change the current user's password.
///
/// Verifies `current_password` against the stored argon2 hash (401 on
/// mismatch, matching the login contract). Validates `new_password`
/// against the same min-length rule used at registration. On success,
/// inside a single transaction:
/// - UPDATE users.password_hash with the new argon2 hash
/// - DELETE all existing sessions for this user (signs out other
/// devices; the stolen-cookie attack surface is closed)
/// - INSERT a fresh session and return it as a new cookie so the
/// caller stays logged in
///
/// Bot tokens (`api_tokens`) are left alone: the user opted into them
/// explicitly and can revoke individually via DELETE /auth/tokens/{id}.
async fn change_password(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
jar: CookieJar,
Json(input): Json<ChangePassword>,
) -> AppResult<impl IntoResponse> {
if !verify_password(&input.current_password, &user.password_hash) {
return Err(AppError::Unauthenticated);
}
validate_password(&input.new_password)?;
let new_hash = hash_password(&input.new_password)?;
let mut tx = state.db.begin().await?;
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
.bind(&new_hash)
.bind(user.id)
.execute(&mut *tx)
.await?;
repo::session::delete_all_for_user(&mut *tx, user.id).await?;
let (raw, hash) = generate_token();
let expires_at = Utc::now() + Duration::days(state.auth.session_ttl_days);
repo::session::create(&mut *tx, user.id, &hash, expires_at).await?;
tx.commit().await?;
let jar = jar.add(build_session_cookie(raw, &state.auth));
Ok((StatusCode::NO_CONTENT, jar))
}
async fn create_token(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,