This commit is contained in:
2026-04-21 19:17:31 +08:00
parent d234d27cc0
commit 89129ef1f4
83 changed files with 13329 additions and 176 deletions

View File

@@ -10,7 +10,10 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T
use tracing::{Level, info_span};
use crate::{
assets::{create_direct_upload_ticket, get_asset_read_url},
assets::{
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
create_sts_upload_credentials, get_asset_read_url,
},
auth::{
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
require_bearer_auth,
@@ -23,10 +26,12 @@ use crate::{
logout::logout,
logout_all::logout_all,
password_entry::password_entry,
phone_auth::{phone_login, send_phone_code},
refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
state::AppState,
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
};
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
@@ -52,10 +57,7 @@ pub fn build_router(state: AppState) -> Router {
attach_refresh_session_token,
)),
)
.route(
"/api/auth/login-options",
get(auth_login_options),
)
.route("/api/auth/login-options", get(auth_login_options))
.route(
"/api/auth/me",
get(auth_me).route_layer(middleware::from_fn_with_state(
@@ -82,6 +84,17 @@ pub fn build_router(state: AppState) -> Router {
attach_refresh_session_token,
)),
)
.route("/api/auth/phone/send-code", post(send_phone_code))
.route("/api/auth/phone/login", post(phone_login))
.route("/api/auth/wechat/start", get(start_wechat_login))
.route("/api/auth/wechat/callback", get(handle_wechat_callback))
.route(
"/api/auth/wechat/bind-phone",
post(bind_wechat_phone).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/logout",
post(logout)
@@ -105,6 +118,15 @@ pub fn build_router(state: AppState) -> Router {
"/api/assets/direct-upload-tickets",
post(create_direct_upload_ticket),
)
.route(
"/api/assets/sts-upload-credentials",
post(create_sts_upload_credentials),
)
.route("/api/assets/objects/confirm", post(confirm_asset_object))
.route(
"/api/assets/objects/bind",
post(bind_asset_object_to_entity),
)
.route("/api/assets/read-url", get(get_asset_read_url))
.route("/api/auth/entry", post(password_entry))
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
@@ -479,6 +501,858 @@ mod tests {
);
}
#[tokio::test]
async fn send_phone_code_returns_mock_cooldown_and_expire_seconds() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["cooldownSeconds"],
Value::Number(serde_json::Number::from(60))
);
assert_eq!(
payload["expiresInSeconds"],
Value::Number(serde_json::Number::from(300))
);
assert_eq!(payload["providerRequestId"], Value::Null);
}
#[tokio::test]
async fn send_phone_code_rejects_same_scene_during_cooldown() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let first_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("first request should build"),
)
.await
.expect("first request should succeed");
assert_eq!(first_response.status(), StatusCode::OK);
let cooldown_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("cooldown request should build"),
)
.await
.expect("cooldown request should succeed");
assert_eq!(cooldown_response.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(
cooldown_response
.headers()
.get("retry-after")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.parse::<u64>().is_ok_and(|seconds| seconds > 0))
);
let body = cooldown_response
.into_body()
.collect()
.await
.expect("cooldown body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("cooldown body should be valid json");
assert_eq!(
payload["error"]["code"],
Value::String("TOO_MANY_REQUESTS".to_string())
);
assert_eq!(
payload["error"]["message"],
Value::String("验证码发送过于频繁,请稍后再试".to_string())
);
assert!(
payload["error"]["details"]["retryAfterSeconds"]
.as_u64()
.is_some()
);
}
#[tokio::test]
async fn phone_login_creates_user_and_sets_refresh_cookie() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_response.status(), StatusCode::OK);
let login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"code": "123456"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
assert_eq!(login_response.status(), StatusCode::OK);
assert!(
login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("genarrative_refresh_session="))
);
let body = login_response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert!(payload["token"].as_str().is_some());
assert_eq!(
payload["user"]["loginMethod"],
Value::String("phone".to_string())
);
assert_eq!(
payload["user"]["bindingStatus"],
Value::String("active".to_string())
);
assert_eq!(
payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string())
);
}
#[tokio::test]
async fn phone_login_reuses_existing_user_for_same_phone_number() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13900139000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_response.status(), StatusCode::OK);
let first_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13900139000",
"code": "123456"
})
.to_string(),
))
.expect("first login request should build"),
)
.await
.expect("first login request should succeed");
assert_eq!(first_login_response.status(), StatusCode::OK);
let first_body = first_login_response
.into_body()
.collect()
.await
.expect("first login body should collect")
.to_bytes();
let first_payload: Value =
serde_json::from_slice(&first_body).expect("first login payload should be json");
let send_code_again_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13900139000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_again_response.status(), StatusCode::OK);
let second_login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13900139000",
"code": "123456"
})
.to_string(),
))
.expect("second login request should build"),
)
.await
.expect("second login request should succeed");
assert_eq!(second_login_response.status(), StatusCode::OK);
let second_body = second_login_response
.into_body()
.collect()
.await
.expect("second login body should collect")
.to_bytes();
let second_payload: Value =
serde_json::from_slice(&second_body).expect("second login payload should be json");
assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]);
}
#[tokio::test]
async fn phone_login_exhausts_code_after_too_many_wrong_attempts() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_response.status(), StatusCode::OK);
for _ in 0..4 {
let wrong_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"code": "000000"
})
.to_string(),
))
.expect("wrong login request should build"),
)
.await
.expect("wrong login request should succeed");
assert_eq!(wrong_response.status(), StatusCode::BAD_REQUEST);
}
let exhausted_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"code": "000000"
})
.to_string(),
))
.expect("exhausted login request should build"),
)
.await
.expect("exhausted login request should succeed");
assert_eq!(exhausted_response.status(), StatusCode::TOO_MANY_REQUESTS);
let exhausted_body = exhausted_response
.into_body()
.collect()
.await
.expect("exhausted body should collect")
.to_bytes();
let exhausted_payload: Value =
serde_json::from_slice(&exhausted_body).expect("exhausted payload should be json");
assert_eq!(
exhausted_payload["error"]["message"],
Value::String("验证码错误次数过多,请重新获取验证码".to_string())
);
let stale_right_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"code": "123456"
})
.to_string(),
))
.expect("stale login request should build"),
)
.await
.expect("stale login request should succeed");
assert_eq!(stale_right_code_response.status(), StatusCode::BAD_REQUEST);
let resend_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"scene": "login"
})
.to_string(),
))
.expect("resend request should build"),
)
.await
.expect("resend request should succeed");
assert_eq!(resend_response.status(), StatusCode::OK);
let login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"code": "123456"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
assert_eq!(login_response.status(), StatusCode::OK);
}
#[tokio::test]
async fn wechat_start_returns_mock_callback_url_with_state() {
let config = AppConfig {
wechat_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/api/auth/wechat/start?redirectPath=%2Fplay")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
let authorization_url = payload["authorizationUrl"]
.as_str()
.expect("authorization url should exist");
assert!(authorization_url.contains("/api/auth/wechat/callback"));
assert!(authorization_url.contains("mock_code=wx-mock-code"));
assert!(authorization_url.contains("state="));
}
#[tokio::test]
async fn wechat_callback_creates_pending_bind_phone_session_with_wechat_provider() {
let config = AppConfig {
wechat_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config.clone()).expect("state should build"));
let start_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/wechat/start?redirectPath=%2Fplay")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("wechat start should succeed");
let start_body = start_response
.into_body()
.collect()
.await
.expect("wechat start body should collect")
.to_bytes();
let start_payload: Value =
serde_json::from_slice(&start_body).expect("wechat start payload should be json");
let authorization_url = start_payload["authorizationUrl"]
.as_str()
.expect("authorization url should exist");
let callback_url =
url::Url::parse(authorization_url).expect("authorization url should be valid");
let state = callback_url
.query_pairs()
.find(|(key, _)| key == "state")
.map(|(_, value)| value.into_owned())
.expect("state query should exist");
let callback_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/api/auth/wechat/callback?state={state}&mock_code=wx-mock-code"
))
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("callback request should build"),
)
.await
.expect("callback request should succeed");
assert_eq!(callback_response.status(), StatusCode::SEE_OTHER);
let location = callback_response
.headers()
.get("location")
.and_then(|value| value.to_str().ok())
.expect("redirect location should exist");
let refresh_cookie = callback_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("refresh cookie should exist");
assert!(location.starts_with("/play#"));
assert!(location.contains("auth_provider=wechat"));
assert!(location.contains("auth_binding_status=pending_bind_phone"));
assert!(location.contains("auth_token="));
assert!(refresh_cookie.contains("genarrative_refresh_session="));
let auth_hash = location
.split('#')
.nth(1)
.expect("hash fragment should exist");
let auth_params = url::form_urlencoded::parse(auth_hash.as_bytes())
.into_owned()
.collect::<std::collections::HashMap<String, String>>();
let token = auth_params
.get("auth_token")
.expect("auth token should exist in hash");
let me_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("auth me request should build"),
)
.await
.expect("auth me request should succeed");
assert_eq!(me_response.status(), StatusCode::OK);
let me_body = me_response
.into_body()
.collect()
.await
.expect("auth me body should collect")
.to_bytes();
let me_payload: Value =
serde_json::from_slice(&me_body).expect("auth me payload should be json");
assert_eq!(
me_payload["user"]["loginMethod"],
Value::String("wechat".to_string())
);
assert_eq!(
me_payload["user"]["bindingStatus"],
Value::String("pending_bind_phone".to_string())
);
let claims_response = app
.oneshot(
Request::builder()
.uri("/_internal/auth/claims")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("claims request should build"),
)
.await
.expect("claims request should succeed");
let claims_body = claims_response
.into_body()
.collect()
.await
.expect("claims body should collect")
.to_bytes();
let claims_payload: Value =
serde_json::from_slice(&claims_body).expect("claims payload should be json");
assert_eq!(
claims_payload["claims"]["provider"],
Value::String("wechat".to_string())
);
assert_eq!(
claims_payload["claims"]["binding_status"],
Value::String("pending_bind_phone".to_string())
);
assert_eq!(
claims_payload["claims"]["phone_verified"],
Value::Bool(false)
);
}
#[tokio::test]
async fn wechat_bind_phone_merges_into_existing_phone_user() {
let config = AppConfig {
sms_auth_enabled: true,
wechat_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let phone_send_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("phone send request should build"),
)
.await
.expect("phone send request should succeed");
assert_eq!(phone_send_response.status(), StatusCode::OK);
let phone_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"code": "123456"
})
.to_string(),
))
.expect("phone login request should build"),
)
.await
.expect("phone login request should succeed");
let phone_login_body = phone_login_response
.into_body()
.collect()
.await
.expect("phone login body should collect")
.to_bytes();
let phone_login_payload: Value =
serde_json::from_slice(&phone_login_body).expect("phone login payload should be json");
let phone_user_id = phone_login_payload["user"]["id"].clone();
let wechat_start_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/wechat/start?redirectPath=%2Fplay")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("wechat start request should build"),
)
.await
.expect("wechat start request should succeed");
let wechat_start_body = wechat_start_response
.into_body()
.collect()
.await
.expect("wechat start body should collect")
.to_bytes();
let wechat_start_payload: Value = serde_json::from_slice(&wechat_start_body)
.expect("wechat start payload should be json");
let authorization_url = wechat_start_payload["authorizationUrl"]
.as_str()
.expect("wechat authorization url should exist");
let callback_state = url::Url::parse(authorization_url)
.expect("authorization url should be valid")
.query_pairs()
.find(|(key, _)| key == "state")
.map(|(_, value)| value.into_owned())
.expect("state should exist");
let wechat_callback_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/api/auth/wechat/callback?state={callback_state}&mock_code=wx-mock-code"
))
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("wechat callback request should build"),
)
.await
.expect("wechat callback request should succeed");
let wechat_location = wechat_callback_response
.headers()
.get("location")
.and_then(|value| value.to_str().ok())
.expect("wechat callback location should exist");
let wechat_hash = wechat_location
.split('#')
.nth(1)
.expect("wechat callback hash should exist");
let wechat_auth_params = url::form_urlencoded::parse(wechat_hash.as_bytes())
.into_owned()
.collect::<std::collections::HashMap<String, String>>();
let wechat_token = wechat_auth_params
.get("auth_token")
.expect("wechat auth token should exist");
let bind_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "bind_phone"
})
.to_string(),
))
.expect("bind code request should build"),
)
.await
.expect("bind code request should succeed");
assert_eq!(bind_code_response.status(), StatusCode::OK);
let bind_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/wechat/bind-phone")
.header("authorization", format!("Bearer {wechat_token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"code": "123456"
})
.to_string(),
))
.expect("bind request should build"),
)
.await
.expect("bind request should succeed");
assert_eq!(bind_response.status(), StatusCode::OK);
assert!(
bind_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("genarrative_refresh_session="))
);
let bind_body = bind_response
.into_body()
.collect()
.await
.expect("bind body should collect")
.to_bytes();
let bind_payload: Value =
serde_json::from_slice(&bind_body).expect("bind payload should be json");
assert_eq!(bind_payload["user"]["id"], phone_user_id);
assert_eq!(
bind_payload["user"]["bindingStatus"],
Value::String("active".to_string())
);
assert_eq!(
bind_payload["user"]["loginMethod"],
Value::String("phone".to_string())
);
assert_eq!(bind_payload["user"]["wechatBound"], Value::Bool(true));
assert_eq!(
bind_payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string())
);
}
#[tokio::test]
async fn auth_sessions_returns_multi_device_session_fields() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -1178,8 +2052,8 @@ mod tests {
.await
.expect("logout-all body should collect")
.to_bytes();
let logout_all_payload: Value = serde_json::from_slice(&logout_all_body)
.expect("logout-all payload should be json");
let logout_all_payload: Value =
serde_json::from_slice(&logout_all_body).expect("logout-all payload should be json");
assert_eq!(logout_all_payload["ok"], Value::Bool(true));
let me_response = app
@@ -1279,4 +2153,4 @@ mod tests {
.is_some_and(|value| value.contains("Max-Age=0"))
);
}
}
}