Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions src/service/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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();

Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
57 changes: 57 additions & 0 deletions src/service/validation.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
6 changes: 3 additions & 3 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,16 +129,16 @@ pub async fn new_test_state(tmp_dir: &Path) -> anyhow::Result<AppState> {
})
}

/// 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();

Expand Down
20 changes: 10 additions & 10 deletions tests/general_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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) =
Expand Down
10 changes: 5 additions & 5 deletions tests/totp_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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) =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 8 additions & 8 deletions tests/yggdrasil_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}
}),
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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();

Expand Down
Loading