From e3ac494e85cbe5f4705121a96e0d12f88f4eb2f2 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 27 Apr 2026 02:59:27 +0000 Subject: [PATCH] feat(server): validate username length/chars and minimum password length on register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Username: 3–32 chars, alphanumeric + underscore only. Password: minimum 8 characters. Both return HTTP 400 Bad Request with a human-readable message. Adds three integration tests for the new validation rules. Co-Authored-By: Claude Sonnet 4.6 --- solitaire_server/src/auth.rs | 37 +++++++++++++++++--- solitaire_server/tests/server_tests.rs | 48 ++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/solitaire_server/src/auth.rs b/solitaire_server/src/auth.rs index ef21b49..0498e08 100644 --- a/solitaire_server/src/auth.rs +++ b/solitaire_server/src/auth.rs @@ -95,20 +95,47 @@ pub fn make_refresh_token(user_id: &str, secret: &str) -> Result bool { + s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + pub async fn register( State(pool): State, Json(body): Json, ) -> Result, AppError> { - // Validate input minimally. - if body.username.trim().is_empty() || body.password.is_empty() { - return Err(AppError::BadRequest("username and password are required".into())); + // Validate username: 3–32 characters, alphanumeric + underscores only. + let trimmed = body.username.trim(); + if trimmed.len() < USERNAME_MIN || trimmed.len() > USERNAME_MAX { + return Err(AppError::BadRequest(format!( + "username must be {USERNAME_MIN}–{USERNAME_MAX} characters" + ))); } + if !username_chars_ok(trimmed) { + return Err(AppError::BadRequest( + "username may only contain letters, digits, and underscores".into(), + )); + } + // Validate password: minimum 8 characters. + if body.password.len() < PASSWORD_MIN { + return Err(AppError::BadRequest(format!( + "password must be at least {PASSWORD_MIN} characters" + ))); + } + + let username = trimmed.to_string(); // Check for duplicate username. SQLite returns TEXT as nullable so we // flatten the Option> produced by fetch_optional. let existing: Option = sqlx::query_scalar!( "SELECT id FROM users WHERE username = ?", - body.username + username ) .fetch_optional(&pool) .await? @@ -125,7 +152,7 @@ pub async fn register( sqlx::query!( "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)", user_id, - body.username, + username, password_hash, now ) diff --git a/solitaire_server/tests/server_tests.rs b/solitaire_server/tests/server_tests.rs index cbd6ed1..ddbe6d4 100644 --- a/solitaire_server/tests/server_tests.rs +++ b/solitaire_server/tests/server_tests.rs @@ -208,7 +208,7 @@ async fn register_creates_account_and_returns_tokens() { let resp = post_json( app, "/api/auth/register", - serde_json::json!({ "username": "alice", "password": "hunter2" }), + serde_json::json!({ "username": "alice", "password": "hunter2!" }), ) .await; @@ -229,7 +229,7 @@ async fn register_creates_account_and_returns_tokens() { async fn register_duplicate_username_returns_conflict() { set_jwt_secret(); let app = build_test_router(test_pool().await); - let creds = serde_json::json!({ "username": "bob", "password": "secret" }); + let creds = serde_json::json!({ "username": "bob", "password": "s3cr3t!!" }); // First registration succeeds. let first = post_json(app.clone(), "/api/auth/register", creds.clone()).await; @@ -244,6 +244,48 @@ async fn register_duplicate_username_returns_conflict() { ); } +/// Short username (< 3 chars) is rejected with 400. +#[tokio::test] +async fn register_rejects_short_username() { + set_jwt_secret(); + let app = build_test_router(test_pool().await); + let resp = post_json( + app, + "/api/auth/register", + serde_json::json!({ "username": "ab", "password": "validpassword" }), + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +/// Username with disallowed characters is rejected with 400. +#[tokio::test] +async fn register_rejects_invalid_username_chars() { + set_jwt_secret(); + let app = build_test_router(test_pool().await); + let resp = post_json( + app, + "/api/auth/register", + serde_json::json!({ "username": "bad name!", "password": "validpassword" }), + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +/// Password shorter than 8 characters is rejected with 400. +#[tokio::test] +async fn register_rejects_short_password() { + set_jwt_secret(); + let app = build_test_router(test_pool().await); + let resp = post_json( + app, + "/api/auth/register", + serde_json::json!({ "username": "validuser", "password": "short" }), + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + /// `POST /api/auth/login` with correct credentials returns 200 with both tokens. #[tokio::test] async fn login_with_correct_credentials_returns_tokens() { @@ -433,7 +475,7 @@ async fn pull_before_push_returns_default_payload() { set_jwt_secret(); let app = build_test_router(test_pool().await); - let (access, _) = register_user(app.clone(), "ivan", "nopush").await; + let (access, _) = register_user(app.clone(), "ivan", "nopush!!").await; let resp = get_authed(app, "/api/sync/pull", &access).await; assert_eq!(resp.status(), StatusCode::OK, "pull with no data must return 200");