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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user