From 75146847f6c34e01d2b7601ec3883ddc82493051 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 12 May 2026 14:10:13 -0700 Subject: [PATCH] feat(server): add --reset-password admin subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosters can now run: ./solitaire_server --reset-password to update a player's password and invalidate all their refresh tokens (forcing re-login on every device). Password is read from stdin so it can be piped from scripts or a password manager without appearing in shell history. Implementation: - reset_password() in auth.rs: validates length, bcrypt-hashes new password, updates users.password_hash, deletes all refresh_tokens rows for the user. - main.rs: --reset-password dispatch before HTTP server startup; JWT_SECRET not required for this path. - 4 integration tests covering: login works after reset, old password rejected, refresh tokens invalidated, unknown user → NotFound, short password → BadRequest. - README_SERVER.md: admin password-reset section with examples. Co-Authored-By: Claude Sonnet 4.6 --- ...8ea3894248406cc016020a6e211ed66da91c0.json | 12 ++ ...b132e962480b0530d542102c05aa9e901463b.json | 12 ++ README_SERVER.md | 26 ++++ solitaire_server/src/auth.rs | 60 ++++++++- solitaire_server/src/lib.rs | 2 + solitaire_server/src/main.rs | 72 +++++++++- solitaire_server/tests/server_tests.rs | 125 ++++++++++++++++++ 7 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 .sqlx/query-40db0910531d4418d4d58d31f0f8ea3894248406cc016020a6e211ed66da91c0.json create mode 100644 .sqlx/query-e4eb622073cbdf868ec1568a6bdb132e962480b0530d542102c05aa9e901463b.json diff --git a/.sqlx/query-40db0910531d4418d4d58d31f0f8ea3894248406cc016020a6e211ed66da91c0.json b/.sqlx/query-40db0910531d4418d4d58d31f0f8ea3894248406cc016020a6e211ed66da91c0.json new file mode 100644 index 0000000..81dc146 --- /dev/null +++ b/.sqlx/query-40db0910531d4418d4d58d31f0f8ea3894248406cc016020a6e211ed66da91c0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM refresh_tokens WHERE user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "40db0910531d4418d4d58d31f0f8ea3894248406cc016020a6e211ed66da91c0" +} diff --git a/.sqlx/query-e4eb622073cbdf868ec1568a6bdb132e962480b0530d542102c05aa9e901463b.json b/.sqlx/query-e4eb622073cbdf868ec1568a6bdb132e962480b0530d542102c05aa9e901463b.json new file mode 100644 index 0000000..85b5f49 --- /dev/null +++ b/.sqlx/query-e4eb622073cbdf868ec1568a6bdb132e962480b0530d542102c05aa9e901463b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE users SET password_hash = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "e4eb622073cbdf868ec1568a6bdb132e962480b0530d542102c05aa9e901463b" +} diff --git a/README_SERVER.md b/README_SERVER.md index 94934fa..5f51442 100644 --- a/README_SERVER.md +++ b/README_SERVER.md @@ -42,3 +42,29 @@ git pull docker compose build docker compose up -d ``` + + +## Admin — Password Reset + +If a player loses access to their account, the server binary includes a +built-in password reset command. Run it on the host (or inside the container) +with `DATABASE_URL` pointing at your database: + +```bash +# Interactive (prompts for the new password): +DATABASE_URL=sqlite://./data/solitaire.db \ + ./solitaire_server --reset-password + +# Non-interactive (piped from a script or password manager): +echo "new_password" | \ + DATABASE_URL=sqlite://./data/solitaire.db \ + ./solitaire_server --reset-password + +# Inside a running Docker container: +docker compose exec server sh -c \ + 'echo "new_password" | ./solitaire_server --reset-password alice' +``` + +On success the user's `password_hash` is updated and **all active refresh +tokens are deleted**, so every open session must log in again with the new +password. `JWT_SECRET` does not need to be set for this command. diff --git a/solitaire_server/src/auth.rs b/solitaire_server/src/auth.rs index c545846..5e30132 100644 --- a/solitaire_server/src/auth.rs +++ b/solitaire_server/src/auth.rs @@ -63,7 +63,7 @@ struct UserRow { // --------------------------------------------------------------------------- /// bcrypt work factor. Cost 12 ≈ 300 ms on modern hardware — balances security against registration latency. -const BCRYPT_COST: u32 = 12; +pub const BCRYPT_COST: u32 = 12; // --------------------------------------------------------------------------- // Token generation helpers @@ -128,7 +128,7 @@ async fn store_refresh_jti( const USERNAME_MIN: usize = 3; const USERNAME_MAX: usize = 32; /// Minimum password length. -const PASSWORD_MIN: usize = 8; +pub const PASSWORD_MIN: usize = 8; /// Returns `true` if every character in `s` is ASCII alphanumeric or `_`. fn username_chars_ok(s: &str) -> bool { @@ -302,6 +302,62 @@ pub async fn delete_account( Ok(Json(serde_json::json!({ "ok": true }))) } +// --------------------------------------------------------------------------- +// Admin helpers (CLI use only — not exposed via HTTP) +// --------------------------------------------------------------------------- + +/// Reset the password for `username` to `new_password`. +/// +/// On success: +/// - The `password_hash` column in `users` is overwritten with a fresh bcrypt +/// hash of `new_password`. +/// - **All** active refresh tokens for the user are deleted, forcing every +/// existing session to re-authenticate before it can issue new access tokens. +/// +/// Returns `AppError::NotFound` when no account with `username` exists. +/// Returns `AppError::BadRequest` when `new_password` is shorter than +/// [`PASSWORD_MIN`]. +pub async fn reset_password( + pool: &sqlx::SqlitePool, + username: &str, + new_password: &str, +) -> Result<(), AppError> { + if new_password.len() < PASSWORD_MIN { + return Err(AppError::BadRequest(format!( + "password must be at least {PASSWORD_MIN} characters" + ))); + } + + let user_id: Option = sqlx::query_scalar!( + "SELECT id FROM users WHERE username = ?", + username + ) + .fetch_optional(pool) + .await? + .flatten(); + + let user_id = + user_id.ok_or_else(|| AppError::NotFound(format!("user '{username}' not found")))?; + + let new_hash = hash(new_password, BCRYPT_COST)?; + + sqlx::query!( + "UPDATE users SET password_hash = ? WHERE id = ?", + new_hash, + user_id + ) + .execute(pool) + .await?; + + // Invalidate all active sessions — the user must log in again with the + // new password before refresh tokens work. + sqlx::query!("DELETE FROM refresh_tokens WHERE user_id = ?", user_id) + .execute(pool) + .await?; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs index d5608c0..22a50a8 100644 --- a/solitaire_server/src/lib.rs +++ b/solitaire_server/src/lib.rs @@ -12,6 +12,8 @@ pub mod middleware; pub mod replays; pub mod sync; +pub use auth::reset_password; + use axum::{ extract::DefaultBodyLimit, middleware as axum_middleware, diff --git a/solitaire_server/src/main.rs b/solitaire_server/src/main.rs index 158d0a7..5b42cff 100644 --- a/solitaire_server/src/main.rs +++ b/solitaire_server/src/main.rs @@ -15,10 +15,28 @@ //! | Variable | Default | Description | //! |---------------|---------|-------------------------------| //! | `SERVER_PORT` | `8080` | TCP port to listen on | +//! +//! ## Admin subcommands +//! +//! Pass `--reset-password ` to reset a player's password instead +//! of starting the HTTP server. The new password is read from stdin (one line). +//! All active sessions for the user are invalidated so the player must log in +//! again with the new password. +//! +//! ```sh +//! # Interactive (password echoed to terminal): +//! ./solitaire_server --reset-password alice +//! +//! # Non-interactive / scripted: +//! echo "new_password" | ./solitaire_server --reset-password alice +//! ``` use solitaire_server::{build_router, AppState}; use sqlx::SqlitePool; -use std::net::SocketAddr; +use std::{ + io::{self, BufRead}, + net::SocketAddr, +}; #[tokio::main] async fn main() { @@ -28,6 +46,57 @@ async fn main() { // Initialise structured logging. tracing_subscriber::fmt::init(); + // Dispatch to admin subcommands before starting the HTTP server. + let args: Vec = std::env::args().collect(); + if let Some(pos) = args.iter().position(|a| a == "--reset-password") { + let username = args + .get(pos + 1) + .expect("--reset-password requires a username argument"); + run_reset_password(username).await; + return; + } + + run_server().await; +} + +/// Connect to the database, read a new password from stdin, and reset the +/// password for `username`. Exits non-zero on any error. +async fn run_reset_password(username: &str) { + let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + + let pool = SqlitePool::connect(&db_url) + .await + .expect("failed to connect to database"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("database migration failed"); + + // Read new password from stdin. Print the prompt to stderr so it doesn't + // pollute stdout when the caller pipes the output. + eprint!("New password for '{username}': "); + let mut new_password = String::new(); + io::stdin() + .lock() + .read_line(&mut new_password) + .expect("failed to read password from stdin"); + let new_password = new_password.trim_end_matches(['\n', '\r']); + + match solitaire_server::reset_password(&pool, username, new_password).await { + Ok(()) => { + println!("Password reset for '{username}'. All active sessions invalidated."); + } + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } +} + +/// Start the HTTP server. Requires `DATABASE_URL`, `JWT_SECRET` (and +/// optionally `SERVER_PORT`) in the environment. +async fn run_server() { let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); // Load JWT_SECRET once at startup — a missing secret is a fatal configuration error. let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"); @@ -36,7 +105,6 @@ async fn main() { .parse() .expect("SERVER_PORT must be a valid port number"); - // Connect to SQLite and run pending migrations. let pool = SqlitePool::connect(&db_url) .await .expect("failed to connect to database"); diff --git a/solitaire_server/tests/server_tests.rs b/solitaire_server/tests/server_tests.rs index b7aacef..cbb3ba4 100644 --- a/solitaire_server/tests/server_tests.rs +++ b/solitaire_server/tests/server_tests.rs @@ -1462,6 +1462,131 @@ async fn leaderboard_with_valid_token_returns_empty_array_when_no_opts() { ); } +// --------------------------------------------------------------------------- +// Admin password reset tests +// --------------------------------------------------------------------------- + +/// `reset_password` updates `password_hash` so the user can log in with the +/// new password and is locked out with the old one. +#[tokio::test] +async fn reset_password_allows_login_with_new_password() { + let pool = test_pool().await; + let app = build_test_router(pool.clone()); + + // Register an account. + let resp = post_json( + app.clone(), + "/api/auth/register", + serde_json::json!({ "username": "reset_user_a", "password": "oldpass1!" }), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Reset password via the admin helper. + solitaire_server::reset_password(&pool, "reset_user_a", "newpass99!") + .await + .expect("reset_password must succeed for an existing user"); + + // Login with new password must succeed. + let new_resp = post_json( + app.clone(), + "/api/auth/login", + serde_json::json!({ "username": "reset_user_a", "password": "newpass99!" }), + ) + .await; + assert_eq!( + new_resp.status(), + StatusCode::OK, + "login with new password must succeed" + ); + + // Login with old password must fail. + let old_resp = post_json( + app, + "/api/auth/login", + serde_json::json!({ "username": "reset_user_a", "password": "oldpass1!" }), + ) + .await; + assert_eq!( + old_resp.status(), + StatusCode::UNAUTHORIZED, + "login with old password must be rejected after reset" + ); +} + +/// `reset_password` deletes all refresh tokens for the user so existing +/// sessions cannot refresh without re-logging in. +#[tokio::test] +async fn reset_password_invalidates_existing_sessions() { + let pool = test_pool().await; + let app = build_test_router(pool.clone()); + + let (_, refresh_token) = + register_user(app.clone(), "reset_user_b", "password1!").await; + + // Confirm the refresh token works before the reset. + let pre_reset = post_json( + app.clone(), + "/api/auth/refresh", + serde_json::json!({ "refresh_token": &refresh_token }), + ) + .await; + assert_eq!( + pre_reset.status(), + StatusCode::OK, + "refresh must work before password reset" + ); + + // Reset the password. + solitaire_server::reset_password(&pool, "reset_user_b", "brandnewpass!") + .await + .expect("reset_password must succeed"); + + // The original refresh token must now be rejected with 401. + let post_reset = post_json( + app, + "/api/auth/refresh", + serde_json::json!({ "refresh_token": &refresh_token }), + ) + .await; + assert_eq!( + post_reset.status(), + StatusCode::UNAUTHORIZED, + "refresh token from before the reset must be invalidated" + ); +} + +/// `reset_password` returns `AppError::NotFound` for a username that does +/// not exist, rather than silently succeeding. +#[tokio::test] +async fn reset_password_returns_not_found_for_unknown_user() { + let pool = test_pool().await; + let err = solitaire_server::reset_password(&pool, "no_such_user", "somepassword!") + .await + .expect_err("reset_password must fail for an unknown username"); + assert!( + matches!(err, solitaire_server::error::AppError::NotFound(_)), + "expected AppError::NotFound, got {err:?}" + ); +} + +/// `reset_password` returns `AppError::BadRequest` when the new password is +/// shorter than the minimum length. +#[tokio::test] +async fn reset_password_rejects_short_password() { + let pool = test_pool().await; + let app = build_test_router(pool.clone()); + register_user(app, "reset_user_c", "password1!").await; + + let err = solitaire_server::reset_password(&pool, "reset_user_c", "short") + .await + .expect_err("reset_password must reject passwords below minimum length"); + assert!( + matches!(err, solitaire_server::error::AppError::BadRequest(_)), + "expected AppError::BadRequest, got {err:?}" + ); +} + // --------------------------------------------------------------------------- // Rate-limiting test (uses the production router with rate limiting enabled) // ---------------------------------------------------------------------------