feat(server): add --reset-password admin subcommand
Self-hosters can now run: ./solitaire_server --reset-password <username> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> = 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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,10 +15,28 @@
|
||||
//! | Variable | Default | Description |
|
||||
//! |---------------|---------|-------------------------------|
|
||||
//! | `SERVER_PORT` | `8080` | TCP port to listen on |
|
||||
//!
|
||||
//! ## Admin subcommands
|
||||
//!
|
||||
//! Pass `--reset-password <username>` 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<String> = 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");
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user