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

@@ -2,14 +2,17 @@
//! the raw value never hits the DB.
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use sqlx::{PgExecutor, PgPool};
use uuid::Uuid;
use crate::domain::Session;
use crate::error::AppResult;
pub async fn create(
pool: &PgPool,
/// Accepts any `PgExecutor` so callers can pass `&PgPool` for simple
/// inserts or `&mut *tx` to run inside a transaction (e.g., password
/// change rotates the user's sessions atomically with the hash UPDATE).
pub async fn create<'e, E: PgExecutor<'e>>(
executor: E,
user_id: Uuid,
token_hash: &[u8],
expires_at: DateTime<Utc>,
@@ -24,11 +27,22 @@ pub async fn create(
.bind(user_id)
.bind(token_hash)
.bind(expires_at)
.fetch_one(pool)
.fetch_one(executor)
.await?;
Ok(row)
}
pub async fn delete_all_for_user<'e, E: PgExecutor<'e>>(
executor: E,
user_id: Uuid,
) -> AppResult<()> {
sqlx::query("DELETE FROM sessions WHERE user_id = $1")
.bind(user_id)
.execute(executor)
.await?;
Ok(())
}
/// Returns the session iff `token_hash` matches and it hasn't expired.
pub async fn find_active(pool: &PgPool, token_hash: &[u8]) -> AppResult<Option<Session>> {
let row = sqlx::query_as::<_, Session>(