diff --git a/src/service/api.rs b/src/service/api.rs index cf753c6..aee553b 100644 --- a/src/service/api.rs +++ b/src/service/api.rs @@ -8,6 +8,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use uuid::Uuid; +use super::validation::{validate_nickname, validate_password}; use crate::service::yggdrasil::types::GameProfile; use crate::service::yggdrasil::types::SkinModel; use crate::{ @@ -241,9 +242,10 @@ async fn patch_user_inner( .await .map_err(|_| Error::error(404, "User not found"))?; - if let Some(new_name) = body.name { + if let Some(ref new_name) = body.name { + validate_nickname(new_name)?; user.update() - .nickname(&new_name) + .nickname(new_name) .exec(&mut db) .await .map_err(|e| { @@ -340,6 +342,7 @@ async fn patch_user_password_inner( } }; + validate_password(&body.new_password)?; state .da .update_user_password(&target_id, &body.new_password) @@ -425,6 +428,8 @@ async fn create_user( .to_string(); let nickname = body.name.unwrap_or_else(|| body.email.clone()); + validate_nickname(&nickname)?; + validate_password(&plain_password)?; let perm_bits = Permission::construct_number(&body.permissions); let user = User::create() @@ -592,6 +597,11 @@ async fn register( } } + // --- Validation --- + validate_password(&body.password)?; + let nickname = body.name.as_deref().unwrap_or(&body.email); + validate_nickname(nickname)?; + // --- Create user --- let mut db = state.da.db().clone(); @@ -610,11 +620,9 @@ async fn register( })? .to_string(); - let nickname = body.name.unwrap_or_else(|| body.email.clone()); - let user = User::create() .email(&body.email) - .nickname(&nickname) + .nickname(nickname) .password(&hashed_password) .preferred_language("zh_CN") .permission(0) diff --git a/src/service/mod.rs b/src/service/mod.rs index 06cc6f4..d489394 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -9,6 +9,7 @@ pub mod api; pub mod frontend; pub mod phenocryst; pub mod types; +pub mod validation; pub mod yggdrasil; pub fn router(state: AppState) -> Router { diff --git a/src/service/validation.rs b/src/service/validation.rs new file mode 100644 index 0000000..b3591a7 --- /dev/null +++ b/src/service/validation.rs @@ -0,0 +1,57 @@ +//! Input validation for user-facing fields (nickname, password, etc.) +//! +//! These functions return a [`crate::service::Error`] on failure, so they can +//! be used directly in API handlers with the `?` operator. + +/// Validate a nickname/username. +/// +/// Rules: +/// - Length: 3–16 characters (inclusive) +/// - Allowed characters: `a-z`, `A-Z`, `0-9`, `_`, `-` +pub fn validate_nickname(name: &str) -> Result<(), super::Error> { + let len = name.chars().count(); + if !(3..=16).contains(&len) { + return Err(super::Error::error( + 422, + format!( + "Nickname must be 3-16 characters, got {} character{}", + len, + if len == 1 { "" } else { "s" }, + ), + )); + } + + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(super::Error::error( + 422, + "Nickname may only contain letters, digits, underscores, and hyphens", + )); + } + + Ok(()) +} + +/// Validate a password. +/// +/// Rules: +/// - Length: 8–128 characters (inclusive) +pub fn validate_password(password: &str) -> Result<(), super::Error> { + let len = password.len(); + if len < 8 { + return Err(super::Error::error( + 422, + format!("Password must be at least 8 characters, got {len}",), + )); + } + if len > 128 { + return Err(super::Error::error( + 422, + format!("Password must be at most 128 characters, got {len}",), + )); + } + + Ok(()) +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 6b1e226..ca556ea 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -129,16 +129,16 @@ pub async fn new_test_state(tmp_dir: &Path) -> anyhow::Result { }) } -/// Password hash for the default test password `"pass"`. +/// Password hash for the default test password `"password123"`. pub fn test_password_hash() -> String { let salt = SaltString::generate(&mut OsRng); Argon2::default() - .hash_password(b"pass", &salt) + .hash_password(b"password123", &salt) .unwrap() .to_string() } -/// Create a test user with the given email and the default password `"pass"`. +/// Create a test user with the given email and the default password `"password123"`. pub async fn create_test_user(state: &AppState, email: &str) -> User { let mut db = state.da.db().clone(); diff --git a/tests/general_api.rs b/tests/general_api.rs index b3420d0..665f521 100644 --- a/tests/general_api.rs +++ b/tests/general_api.rs @@ -135,7 +135,7 @@ async fn general_auth_login_password() { let (status, v) = do_post( &app, "/api/auth/login", - json!({"email": "test@aphanite.example.com", "password": "pass"}), + json!({"email": "test@aphanite.example.com", "password": "password123"}), ) .await; @@ -189,7 +189,7 @@ async fn general_auth_refresh() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let token = login(&app, "test@aphanite.example.com", "pass").await; + let token = login(&app, "test@aphanite.example.com", "password123").await; // Spec: POST /auth/refresh, body empty, Auth required → 200 + 新 token let (status, v) = do_post_auth(&app, "/api/auth/refresh", &token, Value::Null).await; @@ -206,7 +206,7 @@ async fn general_auth_validate() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let token = login(&app, "test@aphanite.example.com", "pass").await; + let token = login(&app, "test@aphanite.example.com", "password123").await; // Spec: GET /auth/validate, Auth required → 204 No Content let (status, _) = do_get_auth(&app, "/api/auth/validate", &token).await; @@ -229,7 +229,7 @@ async fn general_get_current_user() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let token = login(&app, "test@aphanite.example.com", "pass").await; + let token = login(&app, "test@aphanite.example.com", "password123").await; // Spec: GET /users/me, Auth required → 200 + User payload let (status, v) = do_get_auth(&app, "/api/users/me", &token).await; @@ -245,7 +245,7 @@ async fn general_get_user_by_id() { let (app, state, _tmp) = setup().await; let user = create_test_user(&state, "test@aphanite.example.com").await; - let token = login(&app, "test@aphanite.example.com", "pass").await; + let token = login(&app, "test@aphanite.example.com", "password123").await; // Spec: GET /users/{id}, Auth required → 200 + User payload let (status, v) = do_get_auth(&app, &format!("/api/users/{}", user.id), &token).await; @@ -261,14 +261,14 @@ async fn general_patch_current_user_password() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let token = login(&app, "test@aphanite.example.com", "pass").await; + let token = login(&app, "test@aphanite.example.com", "password123").await; // Spec: PATCH /users/me/credentials/password → 204 No Content let (status, _) = do_patch_auth( &app, "/api/users/me/credentials/password", &token, - json!({"old_password": "pass", "new_password": "newpass"}), + json!({"old_password": "password123", "new_password": "newpassword456"}), ) .await; assert_eq!(status, StatusCode::NO_CONTENT); @@ -277,7 +277,7 @@ async fn general_patch_current_user_password() { let (status, _) = do_post( &app, "/api/auth/login", - json!({"email": "test@aphanite.example.com", "password": "pass"}), + json!({"email": "test@aphanite.example.com", "password": "password123"}), ) .await; assert_eq!(status, StatusCode::FORBIDDEN); @@ -286,7 +286,7 @@ async fn general_patch_current_user_password() { let (status, _) = do_post( &app, "/api/auth/login", - json!({"email": "test@aphanite.example.com", "password": "newpass"}), + json!({"email": "test@aphanite.example.com", "password": "newpassword456"}), ) .await; assert_eq!(status, StatusCode::OK); @@ -299,7 +299,7 @@ async fn general_profile_crud() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let token = login(&app, "test@aphanite.example.com", "pass").await; + let token = login(&app, "test@aphanite.example.com", "password123").await; // Create profile: POST /profile → 200 + Profile payload let (status, v) = diff --git a/tests/totp_api.rs b/tests/totp_api.rs index 0b7ae66..f07917f 100644 --- a/tests/totp_api.rs +++ b/tests/totp_api.rs @@ -90,7 +90,7 @@ async fn do_delete_auth(app: &Router, uri: &str, token: &str) -> StatusCode { async fn totp_create() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let token = login(&app, "test@aphanite.example.com", "pass").await; + let token = login(&app, "test@aphanite.example.com", "password123").await; let (status, v) = do_post_auth(&app, "/api/users/me/credentials/totp", &token, Value::Null).await; @@ -114,7 +114,7 @@ async fn totp_create() { async fn totp_delete() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let token = login(&app, "test@aphanite.example.com", "pass").await; + let token = login(&app, "test@aphanite.example.com", "password123").await; // Create TOTP first let (_, v) = do_post_auth(&app, "/api/users/me/credentials/totp", &token, Value::Null).await; @@ -131,7 +131,7 @@ async fn totp_delete() { async fn totp_full_flow_create_verify_login() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let token = login(&app, "test@aphanite.example.com", "pass").await; + let token = login(&app, "test@aphanite.example.com", "password123").await; // Step 1: Create TOTP let (status, v) = @@ -194,7 +194,7 @@ async fn totp_full_flow_create_verify_login() { async fn totp_verification_wrong_code() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let token = login(&app, "test@aphanite.example.com", "pass").await; + let token = login(&app, "test@aphanite.example.com", "password123").await; // Create TOTP let (_, v) = do_post_auth(&app, "/api/users/me/credentials/totp", &token, Value::Null).await; @@ -227,7 +227,7 @@ async fn totp_verification_wrong_code() { async fn totp_verification_fails_after_delete() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let token = login(&app, "test@aphanite.example.com", "pass").await; + let token = login(&app, "test@aphanite.example.com", "password123").await; // Create TOTP let (_, v) = do_post_auth(&app, "/api/users/me/credentials/totp", &token, Value::Null).await; diff --git a/tests/yggdrasil_api.rs b/tests/yggdrasil_api.rs index cea4be1..a4da47d 100644 --- a/tests/yggdrasil_api.rs +++ b/tests/yggdrasil_api.rs @@ -113,7 +113,7 @@ async fn yggdrasil_authenticate_success() { "/authserver/authenticate", json!({ "username": "test@aphanite.example.com", - "password": "pass", + "password": "password123", "clientToken": "client-abc", "requestUser": true, "agent": {"name": "Minecraft", "version": 1} @@ -169,7 +169,7 @@ async fn yggdrasil_authenticate_nonexistent_user() { "/authserver/authenticate", json!({ "username": "nobody@nowhere.com", - "password": "pass", + "password": "password123", "requestUser": false, "agent": {"name": "Minecraft", "version": 1} }), @@ -188,7 +188,7 @@ async fn yggdrasil_refresh_success() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let auth = do_authenticate(&app, "test@aphanite.example.com", "pass").await; + let auth = do_authenticate(&app, "test@aphanite.example.com", "password123").await; let old_token = auth["accessToken"].as_str().unwrap(); let (status, body) = post_json( @@ -238,7 +238,7 @@ async fn yggdrasil_validate_success() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let auth = do_authenticate(&app, "test@aphanite.example.com", "pass").await; + let auth = do_authenticate(&app, "test@aphanite.example.com", "password123").await; let token = auth["accessToken"].as_str().unwrap(); let (status, _) = post_json( @@ -275,7 +275,7 @@ async fn yggdrasil_invalidate() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let auth = do_authenticate(&app, "test@aphanite.example.com", "pass").await; + let auth = do_authenticate(&app, "test@aphanite.example.com", "password123").await; let token = auth["accessToken"].as_str().unwrap(); // Invalidate → 204 No Content @@ -304,14 +304,14 @@ async fn yggdrasil_signout() { let (app, state, _tmp) = setup().await; create_test_user(&state, "test@aphanite.example.com").await; - let auth = do_authenticate(&app, "test@aphanite.example.com", "pass").await; + let auth = do_authenticate(&app, "test@aphanite.example.com", "password123").await; let token = auth["accessToken"].as_str().unwrap(); // Signout → 204 No Content let (status, _) = post_json( &app, "/authserver/signout", - json!({ "username": "test@aphanite.example.com", "password": "pass" }), + json!({ "username": "test@aphanite.example.com", "password": "password123" }), ) .await; assert_eq!(status, StatusCode::NO_CONTENT); @@ -335,7 +335,7 @@ async fn yggdrasil_join_and_has_joined() { let user = create_test_user(&state, "test@aphanite.example.com").await; let profile = create_test_profile(&state, user.id, "TestPlayer").await; - let auth = do_authenticate(&app, "test@aphanite.example.com", "pass").await; + let auth = do_authenticate(&app, "test@aphanite.example.com", "password123").await; let access_token = auth["accessToken"].as_str().unwrap(); let server_id = Uuid::now_v7().to_string();