重写
This commit is contained in:
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,8 @@ use platform_auth::{
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::session_client::SessionClientContext;
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
use crate::{session_client::SessionClientContext};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignedAuthSession {
|
||||
@@ -22,6 +22,15 @@ pub fn create_password_auth_session(
|
||||
state: &AppState,
|
||||
user: &AuthUser,
|
||||
session_client: &SessionClientContext,
|
||||
) -> Result<SignedAuthSession, AppError> {
|
||||
create_auth_session(state, user, session_client, AuthLoginMethod::Password)
|
||||
}
|
||||
|
||||
pub fn create_auth_session(
|
||||
state: &AppState,
|
||||
user: &AuthUser,
|
||||
session_client: &SessionClientContext,
|
||||
session_provider: AuthLoginMethod,
|
||||
) -> Result<SignedAuthSession, AppError> {
|
||||
let refresh_token = create_refresh_session_token();
|
||||
let refresh_token_hash = hash_refresh_session_token(&refresh_token);
|
||||
@@ -31,13 +40,18 @@ pub fn create_password_auth_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash,
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
issued_by_provider: session_provider.clone(),
|
||||
client_info: session_client.to_refresh_session_client_info(),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(map_refresh_session_error)?;
|
||||
let access_token = sign_access_token_for_user(state, user, &session.session.session_id)?;
|
||||
let access_token = sign_access_token_for_user(
|
||||
state,
|
||||
user,
|
||||
&session.session.session_id,
|
||||
Some(&session_provider),
|
||||
)?;
|
||||
|
||||
Ok(SignedAuthSession {
|
||||
access_token,
|
||||
@@ -49,12 +63,13 @@ pub fn sign_access_token_for_user(
|
||||
state: &AppState,
|
||||
user: &AuthUser,
|
||||
session_id: &str,
|
||||
session_provider_override: Option<&AuthLoginMethod>,
|
||||
) -> Result<String, AppError> {
|
||||
let access_claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: user.id.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
provider: map_auth_provider(&user.login_method),
|
||||
provider: map_auth_provider(session_provider_override.unwrap_or(&user.login_method)),
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: user.token_version,
|
||||
phone_verified: user.phone_number_masked.is_some(),
|
||||
|
||||
@@ -70,9 +70,9 @@ pub async fn auth_sessions(
|
||||
.sessions
|
||||
.into_iter()
|
||||
.map(|session| {
|
||||
let is_current = current_refresh_token_hash.as_ref().is_some_and(|hash| {
|
||||
session.refresh_token_hash == *hash
|
||||
});
|
||||
let is_current = current_refresh_token_hash
|
||||
.as_ref()
|
||||
.is_some_and(|hash| session.refresh_token_hash == *hash);
|
||||
let client_label = session.client_info.device_display_name.clone();
|
||||
|
||||
AuthSessionSummaryPayload {
|
||||
@@ -99,8 +99,10 @@ pub async fn auth_sessions(
|
||||
|
||||
fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError {
|
||||
match error {
|
||||
module_auth::RefreshSessionError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("当前登录态已失效,请重新登录"),
|
||||
module_auth::RefreshSessionError::UserNotFound => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("当前登录态已失效,请重新登录")
|
||||
}
|
||||
module_auth::RefreshSessionError::MissingToken
|
||||
| module_auth::RefreshSessionError::SessionNotFound
|
||||
| module_auth::RefreshSessionError::SessionExpired => {
|
||||
|
||||
@@ -16,14 +16,30 @@ pub struct AppConfig {
|
||||
pub refresh_session_ttl_days: u32,
|
||||
pub sms_auth_enabled: bool,
|
||||
pub wechat_auth_enabled: bool,
|
||||
pub wechat_auth_provider: String,
|
||||
pub wechat_app_id: Option<String>,
|
||||
pub wechat_app_secret: Option<String>,
|
||||
pub wechat_callback_path: String,
|
||||
pub wechat_redirect_path: String,
|
||||
pub wechat_authorize_endpoint: String,
|
||||
pub wechat_access_token_endpoint: String,
|
||||
pub wechat_user_info_endpoint: String,
|
||||
pub wechat_state_ttl_minutes: u32,
|
||||
pub wechat_mock_user_id: String,
|
||||
pub wechat_mock_union_id: Option<String>,
|
||||
pub wechat_mock_display_name: String,
|
||||
pub wechat_mock_avatar_url: Option<String>,
|
||||
pub oss_bucket: Option<String>,
|
||||
pub oss_endpoint: Option<String>,
|
||||
pub oss_access_key_id: Option<String>,
|
||||
pub oss_access_key_secret: Option<String>,
|
||||
pub oss_public_base_url: Option<String>,
|
||||
pub oss_read_expire_seconds: u64,
|
||||
pub oss_post_expire_seconds: u64,
|
||||
pub oss_post_max_size_bytes: u64,
|
||||
pub oss_success_action_status: u16,
|
||||
pub spacetime_server_url: String,
|
||||
pub spacetime_database: String,
|
||||
pub spacetime_token: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -42,14 +58,31 @@ impl Default for AppConfig {
|
||||
refresh_session_ttl_days: 30,
|
||||
sms_auth_enabled: false,
|
||||
wechat_auth_enabled: false,
|
||||
wechat_auth_provider: "mock".to_string(),
|
||||
wechat_app_id: None,
|
||||
wechat_app_secret: None,
|
||||
wechat_callback_path: "/api/auth/wechat/callback".to_string(),
|
||||
wechat_redirect_path: "/".to_string(),
|
||||
wechat_authorize_endpoint: "https://open.weixin.qq.com/connect/qrconnect".to_string(),
|
||||
wechat_access_token_endpoint: "https://api.weixin.qq.com/sns/oauth2/access_token"
|
||||
.to_string(),
|
||||
wechat_user_info_endpoint: "https://api.weixin.qq.com/sns/userinfo".to_string(),
|
||||
wechat_state_ttl_minutes: 15,
|
||||
wechat_mock_user_id: "wx-mock-user".to_string(),
|
||||
wechat_mock_union_id: Some("wx-mock-union".to_string()),
|
||||
wechat_mock_display_name: "微信旅人".to_string(),
|
||||
wechat_mock_avatar_url: None,
|
||||
oss_bucket: None,
|
||||
oss_endpoint: None,
|
||||
oss_access_key_id: None,
|
||||
oss_access_key_secret: None,
|
||||
oss_public_base_url: None,
|
||||
oss_read_expire_seconds: 10 * 60,
|
||||
oss_post_expire_seconds: 10 * 60,
|
||||
oss_post_max_size_bytes: 20 * 1024 * 1024,
|
||||
oss_success_action_status: 200,
|
||||
spacetime_server_url: "http://127.0.0.1:3000".to_string(),
|
||||
spacetime_database: "genarrative-dev".to_string(),
|
||||
spacetime_token: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,12 +159,58 @@ impl AppConfig {
|
||||
if let Some(wechat_auth_enabled) = read_first_bool_env(&["WECHAT_AUTH_ENABLED"]) {
|
||||
config.wechat_auth_enabled = wechat_auth_enabled;
|
||||
}
|
||||
if let Some(wechat_auth_provider) = read_first_non_empty_env(&["WECHAT_AUTH_PROVIDER"]) {
|
||||
config.wechat_auth_provider = wechat_auth_provider;
|
||||
}
|
||||
config.wechat_app_id = read_first_non_empty_env(&["WECHAT_APP_ID"]);
|
||||
config.wechat_app_secret = read_first_non_empty_env(&["WECHAT_APP_SECRET"]);
|
||||
if let Some(wechat_callback_path) = read_first_non_empty_env(&["WECHAT_CALLBACK_PATH"]) {
|
||||
config.wechat_callback_path = wechat_callback_path;
|
||||
}
|
||||
if let Some(wechat_redirect_path) = read_first_non_empty_env(&["WECHAT_REDIRECT_PATH"]) {
|
||||
config.wechat_redirect_path = wechat_redirect_path;
|
||||
}
|
||||
if let Some(wechat_authorize_endpoint) =
|
||||
read_first_non_empty_env(&["WECHAT_AUTHORIZE_ENDPOINT"])
|
||||
{
|
||||
config.wechat_authorize_endpoint = wechat_authorize_endpoint;
|
||||
}
|
||||
if let Some(wechat_access_token_endpoint) =
|
||||
read_first_non_empty_env(&["WECHAT_ACCESS_TOKEN_ENDPOINT"])
|
||||
{
|
||||
config.wechat_access_token_endpoint = wechat_access_token_endpoint;
|
||||
}
|
||||
if let Some(wechat_user_info_endpoint) =
|
||||
read_first_non_empty_env(&["WECHAT_USER_INFO_ENDPOINT"])
|
||||
{
|
||||
config.wechat_user_info_endpoint = wechat_user_info_endpoint;
|
||||
}
|
||||
if let Some(wechat_state_ttl_minutes) =
|
||||
read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"])
|
||||
{
|
||||
config.wechat_state_ttl_minutes = wechat_state_ttl_minutes;
|
||||
}
|
||||
if let Some(wechat_mock_user_id) = read_first_non_empty_env(&["WECHAT_MOCK_USER_ID"]) {
|
||||
config.wechat_mock_user_id = wechat_mock_user_id;
|
||||
}
|
||||
config.wechat_mock_union_id = read_first_non_empty_env(&["WECHAT_MOCK_UNION_ID"]);
|
||||
if let Some(wechat_mock_display_name) =
|
||||
read_first_non_empty_env(&["WECHAT_MOCK_DISPLAY_NAME"])
|
||||
{
|
||||
config.wechat_mock_display_name = wechat_mock_display_name;
|
||||
}
|
||||
config.wechat_mock_avatar_url = read_first_non_empty_env(&["WECHAT_MOCK_AVATAR_URL"]);
|
||||
|
||||
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
|
||||
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
|
||||
config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]);
|
||||
config.oss_access_key_secret = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_SECRET"]);
|
||||
config.oss_public_base_url = read_first_non_empty_env(&["ALIYUN_OSS_PUBLIC_BASE_URL"]);
|
||||
|
||||
if let Some(oss_read_expire_seconds) =
|
||||
read_first_duration_seconds_env(&["ALIYUN_OSS_READ_EXPIRE_SECONDS"])
|
||||
{
|
||||
config.oss_read_expire_seconds = oss_read_expire_seconds;
|
||||
}
|
||||
|
||||
if let Some(oss_post_expire_seconds) =
|
||||
read_first_duration_seconds_env(&["ALIYUN_OSS_POST_EXPIRE_SECONDS"])
|
||||
@@ -151,6 +230,20 @@ impl AppConfig {
|
||||
config.oss_success_action_status = oss_success_action_status;
|
||||
}
|
||||
|
||||
if let Some(spacetime_server_url) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"])
|
||||
{
|
||||
config.spacetime_server_url = spacetime_server_url;
|
||||
}
|
||||
|
||||
if let Some(spacetime_database) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_SPACETIME_DATABASE"])
|
||||
{
|
||||
config.spacetime_database = spacetime_database;
|
||||
}
|
||||
|
||||
config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]);
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue},
|
||||
http::StatusCode,
|
||||
http::{HeaderMap, HeaderValue},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
@@ -42,6 +42,10 @@ impl AppError {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
pub fn with_message(mut self, message: impl Into<String>) -> Self {
|
||||
self.message = message.into();
|
||||
self
|
||||
@@ -60,7 +64,8 @@ impl AppError {
|
||||
pub fn into_response_with_context(self, request_context: Option<&RequestContext>) -> Response {
|
||||
let status_code = self.status_code;
|
||||
let payload = self.to_payload();
|
||||
let mut response = (status_code, json_error_body(request_context, &payload)).into_response();
|
||||
let mut response =
|
||||
(status_code, json_error_body(request_context, &payload)).into_response();
|
||||
response.headers_mut().extend(self.headers);
|
||||
response
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ mod app;
|
||||
mod assets;
|
||||
mod auth;
|
||||
mod auth_me;
|
||||
mod auth_sessions;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod config;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
@@ -13,11 +13,14 @@ mod login_options;
|
||||
mod logout;
|
||||
mod logout_all;
|
||||
mod password_entry;
|
||||
mod phone_auth;
|
||||
mod refresh_session;
|
||||
mod request_context;
|
||||
mod response_headers;
|
||||
mod session_client;
|
||||
mod state;
|
||||
mod wechat_auth;
|
||||
mod wechat_provider;
|
||||
|
||||
use shared_logging::init_tracing;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -45,4 +48,4 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听");
|
||||
|
||||
axum::serve(listener, router).await
|
||||
}
|
||||
}
|
||||
|
||||
188
server-rs/crates/api-server/src/phone_auth.rs
Normal file
188
server-rs/crates/api-server/src/phone_auth.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use module_auth::{
|
||||
AuthLoginMethod, PhoneAuthError, PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth_session::{
|
||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
||||
},
|
||||
http_error::AppError,
|
||||
password_entry::PasswordEntryUserPayload,
|
||||
request_context::RequestContext,
|
||||
session_client::resolve_session_client_context,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneSendCodeRequest {
|
||||
pub phone: String,
|
||||
pub scene: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneSendCodeResponse {
|
||||
pub ok: bool,
|
||||
pub cooldown_seconds: u64,
|
||||
pub expires_in_seconds: u64,
|
||||
pub provider_request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneLoginRequest {
|
||||
pub phone: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneLoginResponse {
|
||||
pub token: String,
|
||||
pub user: PasswordEntryUserPayload,
|
||||
}
|
||||
|
||||
pub async fn send_phone_code(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<PhoneSendCodeRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
// 短信登录开关由服务端配置统一控制,避免前端误调用未开放能力。
|
||||
if !state.config.sms_auth_enabled {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
|
||||
);
|
||||
}
|
||||
let scene = map_phone_auth_scene(payload.scene.as_deref())?;
|
||||
let result = state
|
||||
.phone_auth_service()
|
||||
.send_code(
|
||||
SendPhoneCodeInput {
|
||||
phone_number: payload.phone,
|
||||
scene,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(map_phone_auth_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PhoneSendCodeResponse {
|
||||
ok: true,
|
||||
cooldown_seconds: result.cooldown_seconds,
|
||||
expires_in_seconds: result.expires_in_seconds,
|
||||
provider_request_id: result.provider_request_id,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn phone_login(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<PhoneLoginRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 手机号验证码校验通过后,沿用统一会话签发逻辑,确保 refresh cookie 与 JWT 行为一致。
|
||||
if !state.config.sms_auth_enabled {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
|
||||
);
|
||||
}
|
||||
let result = state
|
||||
.phone_auth_service()
|
||||
.login(
|
||||
PhoneLoginInput {
|
||||
phone_number: payload.phone,
|
||||
verify_code: payload.code,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_phone_auth_error)?;
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_auth_session(
|
||||
&state,
|
||||
&result.user,
|
||||
&session_client,
|
||||
AuthLoginMethod::Phone,
|
||||
)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
&mut headers,
|
||||
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
|
||||
);
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
PhoneLoginResponse {
|
||||
token: signed_session.access_token,
|
||||
user: PasswordEntryUserPayload {
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
display_name: result.user.display_name,
|
||||
phone_number_masked: result.user.phone_number_masked,
|
||||
login_method: result.user.login_method.as_str(),
|
||||
binding_status: result.user.binding_status.as_str(),
|
||||
wechat_bound: result.user.wechat_bound,
|
||||
},
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result<PhoneAuthScene, AppError> {
|
||||
match raw_scene.unwrap_or("login").trim() {
|
||||
"login" => Ok(PhoneAuthScene::Login),
|
||||
"bind_phone" => Ok(PhoneAuthScene::BindPhone),
|
||||
"change_phone" => Ok(PhoneAuthScene::ChangePhone),
|
||||
_ => Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("短信验证码场景不合法")
|
||||
.with_details(json!({ "field": "scene" }))),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
|
||||
match error {
|
||||
PhoneAuthError::InvalidPhoneNumber
|
||||
| PhoneAuthError::InvalidVerifyCode
|
||||
| PhoneAuthError::VerifyCodeNotFound
|
||||
| PhoneAuthError::VerifyCodeExpired
|
||||
| PhoneAuthError::UserStateMismatch => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
|
||||
}
|
||||
PhoneAuthError::SendCoolingDown {
|
||||
retry_after_seconds,
|
||||
} => {
|
||||
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
|
||||
.with_message(error.to_string())
|
||||
.with_details(json!({ "retryAfterSeconds": retry_after_seconds }));
|
||||
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
|
||||
Ok(value) => app_error.with_header("retry-after", value),
|
||||
Err(_) => app_error,
|
||||
}
|
||||
}
|
||||
PhoneAuthError::VerifyAttemptsExceeded => {
|
||||
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())
|
||||
}
|
||||
PhoneAuthError::UserNotFound => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
|
||||
}
|
||||
PhoneAuthError::Store(_) | PhoneAuthError::PasswordHash(_) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,12 @@ pub async fn refresh_session(
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(|error| map_refresh_error_with_clear_cookie(&state, error))?;
|
||||
let access_token =
|
||||
sign_access_token_for_user(&state, &rotated.user, &rotated.session.session_id)?;
|
||||
let access_token = sign_access_token_for_user(
|
||||
&state,
|
||||
&rotated.user,
|
||||
&rotated.session.session_id,
|
||||
Some(&rotated.session.issued_by_provider),
|
||||
)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
|
||||
@@ -56,24 +56,30 @@ pub fn resolve_session_client_context(headers: &HeaderMap) -> SessionClientConte
|
||||
normalize_runtime(header_value(headers, X_CLIENT_RUNTIME_HEADER), &ua_lower);
|
||||
let explicit_client_platform =
|
||||
normalize_platform(header_value(headers, X_CLIENT_PLATFORM_HEADER), &ua_lower);
|
||||
let client_instance_id = normalize_optional_string(header_value(
|
||||
headers,
|
||||
X_CLIENT_INSTANCE_ID_HEADER,
|
||||
));
|
||||
let client_instance_id =
|
||||
normalize_optional_string(header_value(headers, X_CLIENT_INSTANCE_ID_HEADER));
|
||||
let mini_program_app_id =
|
||||
normalize_optional_string(header_value(headers, X_MINI_PROGRAM_APP_ID_HEADER));
|
||||
let mini_program_env =
|
||||
normalize_optional_string(header_value(headers, X_MINI_PROGRAM_ENV_HEADER));
|
||||
|
||||
let inferred_client_type = infer_client_type(explicit_client_type.as_deref(), &ua_lower);
|
||||
let inferred_runtime =
|
||||
infer_client_runtime(explicit_client_runtime.as_deref(), &inferred_client_type, &ua_lower);
|
||||
let inferred_runtime = infer_client_runtime(
|
||||
explicit_client_runtime.as_deref(),
|
||||
&inferred_client_type,
|
||||
&ua_lower,
|
||||
);
|
||||
let inferred_platform = infer_client_platform(explicit_client_platform.as_deref(), &ua_lower);
|
||||
let ip = resolve_ip(headers);
|
||||
let device_display_name =
|
||||
build_device_display_name(&inferred_client_type, &inferred_runtime, &inferred_platform);
|
||||
let device_fingerprint =
|
||||
build_device_fingerprint(&inferred_client_type, &inferred_runtime, &inferred_platform, client_instance_id.as_deref(), user_agent.as_deref());
|
||||
let device_fingerprint = build_device_fingerprint(
|
||||
&inferred_client_type,
|
||||
&inferred_runtime,
|
||||
&inferred_platform,
|
||||
client_instance_id.as_deref(),
|
||||
user_agent.as_deref(),
|
||||
);
|
||||
|
||||
SessionClientContext {
|
||||
client_type: inferred_client_type,
|
||||
@@ -160,7 +166,11 @@ fn infer_client_type(explicit_type: Option<&str>, ua_lower: &str) -> String {
|
||||
"web_browser".to_string()
|
||||
}
|
||||
|
||||
fn infer_client_runtime(explicit_runtime: Option<&str>, client_type: &str, ua_lower: &str) -> String {
|
||||
fn infer_client_runtime(
|
||||
explicit_runtime: Option<&str>,
|
||||
client_type: &str,
|
||||
ua_lower: &str,
|
||||
) -> String {
|
||||
if client_type == "mini_program" {
|
||||
if let Some(runtime) = explicit_runtime {
|
||||
return runtime.to_string();
|
||||
@@ -201,7 +211,8 @@ fn infer_runtime_from_user_agent(ua_lower: &str) -> Option<String> {
|
||||
if ua_lower.contains("chrome/") || ua_lower.contains("crios/") {
|
||||
return Some("chrome".to_string());
|
||||
}
|
||||
if ua_lower.contains("safari/") && !ua_lower.contains("chrome/") && !ua_lower.contains("crios/") {
|
||||
if ua_lower.contains("safari/") && !ua_lower.contains("chrome/") && !ua_lower.contains("crios/")
|
||||
{
|
||||
return Some("safari".to_string());
|
||||
}
|
||||
|
||||
@@ -228,7 +239,11 @@ fn infer_platform_from_user_agent(ua_lower: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn build_device_display_name(client_type: &str, client_runtime: &str, client_platform: &str) -> String {
|
||||
fn build_device_display_name(
|
||||
client_type: &str,
|
||||
client_runtime: &str,
|
||||
client_platform: &str,
|
||||
) -> String {
|
||||
// 展示名固定由后端派生,避免前端上传自由文本导致同类设备标签漂移。
|
||||
if client_type == "mini_program" {
|
||||
return format!(
|
||||
@@ -329,7 +344,10 @@ pub fn mask_ip(ip: Option<&str>) -> Option<String> {
|
||||
}
|
||||
|
||||
if ip.contains(':') {
|
||||
let parts = ip.split(':').filter(|part| !part.is_empty()).collect::<Vec<_>>();
|
||||
let parts = ip
|
||||
.split(':')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if parts.len() <= 2 {
|
||||
return Some(ip.to_string());
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use module_auth::{AuthUserService, InMemoryAuthStore, PasswordEntryService, RefreshSessionService};
|
||||
use module_auth::{
|
||||
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
|
||||
RefreshSessionService, WechatAuthService, WechatAuthStateService,
|
||||
};
|
||||
use platform_auth::{
|
||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||
};
|
||||
use platform_oss::{OssClient, OssConfig, OssError};
|
||||
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
|
||||
|
||||
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -20,6 +25,11 @@ pub struct AppState {
|
||||
password_entry_service: PasswordEntryService,
|
||||
refresh_session_service: RefreshSessionService,
|
||||
auth_user_service: AuthUserService,
|
||||
phone_auth_service: PhoneAuthService,
|
||||
wechat_auth_state_service: WechatAuthStateService,
|
||||
wechat_auth_service: WechatAuthService,
|
||||
wechat_provider: WechatProvider,
|
||||
spacetime_client: SpacetimeClient,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -51,8 +61,18 @@ impl AppState {
|
||||
let auth_store = InMemoryAuthStore::default();
|
||||
let password_entry_service = PasswordEntryService::new(auth_store.clone());
|
||||
let auth_user_service = AuthUserService::new(auth_store.clone());
|
||||
let phone_auth_service = PhoneAuthService::new(auth_store.clone());
|
||||
let wechat_auth_state_service =
|
||||
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
|
||||
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
|
||||
let wechat_provider = build_wechat_provider(&config);
|
||||
let refresh_session_service =
|
||||
RefreshSessionService::new(auth_store, config.refresh_session_ttl_days);
|
||||
let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig {
|
||||
server_url: config.spacetime_server_url.clone(),
|
||||
database: config.spacetime_database.clone(),
|
||||
token: config.spacetime_token.clone(),
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
@@ -62,6 +82,11 @@ impl AppState {
|
||||
password_entry_service,
|
||||
refresh_session_service,
|
||||
auth_user_service,
|
||||
phone_auth_service,
|
||||
wechat_auth_state_service,
|
||||
wechat_auth_service,
|
||||
wechat_provider,
|
||||
spacetime_client,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,6 +113,26 @@ impl AppState {
|
||||
pub fn auth_user_service(&self) -> &AuthUserService {
|
||||
&self.auth_user_service
|
||||
}
|
||||
|
||||
pub fn phone_auth_service(&self) -> &PhoneAuthService {
|
||||
&self.phone_auth_service
|
||||
}
|
||||
|
||||
pub fn wechat_auth_state_service(&self) -> &WechatAuthStateService {
|
||||
&self.wechat_auth_state_service
|
||||
}
|
||||
|
||||
pub fn wechat_auth_service(&self) -> &WechatAuthService {
|
||||
&self.wechat_auth_service
|
||||
}
|
||||
|
||||
pub fn wechat_provider(&self) -> &WechatProvider {
|
||||
&self.wechat_provider
|
||||
}
|
||||
|
||||
pub fn spacetime_client(&self) -> &SpacetimeClient {
|
||||
&self.spacetime_client
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AppStateInitError {
|
||||
|
||||
382
server-rs/crates/api-server/src/wechat_auth.rs
Normal file
382
server-rs/crates/api-server/src/wechat_auth.rs
Normal file
@@ -0,0 +1,382 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use module_auth::{
|
||||
AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError,
|
||||
WechatAuthScene,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth_session::{
|
||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
||||
},
|
||||
http_error::AppError,
|
||||
password_entry::PasswordEntryUserPayload,
|
||||
request_context::RequestContext,
|
||||
session_client::resolve_session_client_context,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatStartQuery {
|
||||
pub redirect_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatStartResponse {
|
||||
pub authorization_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WechatCallbackQuery {
|
||||
pub state: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub mock_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatBindPhoneRequest {
|
||||
pub phone: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatBindPhoneResponse {
|
||||
pub token: String,
|
||||
pub user: PasswordEntryUserPayload,
|
||||
}
|
||||
|
||||
pub async fn start_wechat_login(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WechatStartQuery>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if !state.config.wechat_auth_enabled {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
|
||||
}
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_string());
|
||||
let scene = resolve_wechat_scene(user_agent.as_deref())?;
|
||||
let state_record = state
|
||||
.wechat_auth_state_service()
|
||||
.create_state(
|
||||
CreateWechatAuthStateInput {
|
||||
redirect_path: normalize_redirect_path(
|
||||
query.redirect_path.as_deref(),
|
||||
&state.config.wechat_redirect_path,
|
||||
),
|
||||
scene: scene.clone(),
|
||||
request_user_agent: user_agent.clone(),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(map_wechat_auth_error)?;
|
||||
let authorization_url = state.wechat_provider().build_authorization_url(
|
||||
&resolve_wechat_callback_url(&state, &headers)?,
|
||||
&state_record.state.state_token,
|
||||
&scene,
|
||||
)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
WechatStartResponse { authorization_url },
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn handle_wechat_callback(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WechatCallbackQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
if !state.config.wechat_auth_enabled {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
|
||||
}
|
||||
let fallback_redirect = state.config.wechat_redirect_path.clone();
|
||||
let state_token = query
|
||||
.state
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if state_token.is_empty() {
|
||||
return Ok(Redirect::to(&build_auth_result_redirect_url(
|
||||
&fallback_redirect,
|
||||
&[
|
||||
("auth_provider", "wechat"),
|
||||
("auth_error", "微信登录状态已失效,请重新发起登录。"),
|
||||
],
|
||||
))
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let consumed = match state
|
||||
.wechat_auth_state_service()
|
||||
.consume_state(&state_token, OffsetDateTime::now_utc())
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(Redirect::to(&build_auth_result_redirect_url(
|
||||
&fallback_redirect,
|
||||
&[
|
||||
("auth_provider", "wechat"),
|
||||
("auth_error", "微信登录状态已失效,请重新发起登录。"),
|
||||
],
|
||||
))
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
let redirect_path = consumed.state.redirect_path.clone();
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
|
||||
let result = match state
|
||||
.wechat_provider()
|
||||
.resolve_callback_profile(query.code.as_deref(), query.mock_code.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(profile) => state
|
||||
.wechat_auth_service()
|
||||
.resolve_login(module_auth::ResolveWechatLoginInput { profile })
|
||||
.await
|
||||
.map_err(map_wechat_auth_error),
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(result) => {
|
||||
let signed_session = create_auth_session(
|
||||
&state,
|
||||
&result.user,
|
||||
&session_client,
|
||||
AuthLoginMethod::Wechat,
|
||||
)?;
|
||||
let mut response = Redirect::to(&build_auth_result_redirect_url(
|
||||
&redirect_path,
|
||||
&[
|
||||
("auth_provider", "wechat"),
|
||||
("auth_token", signed_session.access_token.as_str()),
|
||||
("auth_binding_status", result.user.binding_status.as_str()),
|
||||
],
|
||||
))
|
||||
.into_response();
|
||||
attach_set_cookie_header(
|
||||
response.headers_mut(),
|
||||
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
Err(error) => Ok(Redirect::to(&build_auth_result_redirect_url(
|
||||
&redirect_path,
|
||||
&[("auth_provider", "wechat"), ("auth_error", error.message())],
|
||||
))
|
||||
.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn bind_wechat_phone(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<WechatBindPhoneRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
if !state.config.wechat_auth_enabled {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
|
||||
}
|
||||
let result = state
|
||||
.phone_auth_service()
|
||||
.bind_wechat_phone(
|
||||
BindWechatPhoneInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
phone_number: payload.phone,
|
||||
verify_code: payload.code,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_wechat_bind_phone_error)?;
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_auth_session(
|
||||
&state,
|
||||
&result.user,
|
||||
&session_client,
|
||||
AuthLoginMethod::Wechat,
|
||||
)?;
|
||||
|
||||
let mut response_headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
&mut response_headers,
|
||||
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
|
||||
);
|
||||
|
||||
Ok((
|
||||
response_headers,
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
WechatBindPhoneResponse {
|
||||
token: signed_session.access_token,
|
||||
user: PasswordEntryUserPayload {
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
display_name: result.user.display_name,
|
||||
phone_number_masked: result.user.phone_number_masked,
|
||||
login_method: result.user.login_method.as_str(),
|
||||
binding_status: result.user.binding_status.as_str(),
|
||||
wechat_bound: result.user.wechat_bound,
|
||||
},
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_wechat_scene(user_agent: Option<&str>) -> Result<WechatAuthScene, AppError> {
|
||||
let user_agent = user_agent.unwrap_or_default();
|
||||
let is_wechat = user_agent.contains("MicroMessenger");
|
||||
let is_mobile = user_agent.contains("Android")
|
||||
|| user_agent.contains("iPhone")
|
||||
|| user_agent.contains("iPad")
|
||||
|| user_agent.contains("Mobile");
|
||||
|
||||
if is_wechat {
|
||||
return Ok(WechatAuthScene::WechatInApp);
|
||||
}
|
||||
if is_mobile {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录"));
|
||||
}
|
||||
|
||||
Ok(WechatAuthScene::Desktop)
|
||||
}
|
||||
|
||||
fn normalize_redirect_path(raw_value: Option<&str>, fallback: &str) -> String {
|
||||
let Some(raw_value) = raw_value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return fallback.to_string();
|
||||
};
|
||||
if raw_value.starts_with('/') {
|
||||
return raw_value.to_string();
|
||||
}
|
||||
Url::parse(raw_value)
|
||||
.map(|url| {
|
||||
format!(
|
||||
"{}{}{}",
|
||||
url.path(),
|
||||
url.query().map(|v| format!("?{v}")).unwrap_or_default(),
|
||||
url.fragment().map(|v| format!("#{v}")).unwrap_or_default()
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|_| fallback.to_string())
|
||||
}
|
||||
|
||||
fn resolve_wechat_callback_url(state: &AppState, headers: &HeaderMap) -> Result<String, AppError> {
|
||||
let proto = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split(',').next())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("http");
|
||||
let host = headers
|
||||
.get("x-forwarded-host")
|
||||
.or_else(|| headers.get("host"))
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split(',').next())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("127.0.0.1:3000");
|
||||
Ok(format!(
|
||||
"{proto}://{host}{}",
|
||||
state.config.wechat_callback_path
|
||||
))
|
||||
}
|
||||
|
||||
fn build_auth_result_redirect_url(redirect_path: &str, params: &[(&str, &str)]) -> String {
|
||||
let hash = params
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
format!(
|
||||
"{}={}",
|
||||
urlencoding::encode(key),
|
||||
urlencoding::encode(value)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
let path_without_hash = redirect_path.split('#').next().unwrap_or("/");
|
||||
format!(
|
||||
"{}#{}",
|
||||
if path_without_hash.is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
path_without_hash
|
||||
},
|
||||
hash
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _assert_response_type(_: Response) {}
|
||||
|
||||
fn map_wechat_auth_error(error: WechatAuthError) -> AppError {
|
||||
match error {
|
||||
WechatAuthError::MissingProfile
|
||||
| WechatAuthError::StateNotFound
|
||||
| WechatAuthError::StateExpired
|
||||
| WechatAuthError::StateConsumed
|
||||
| WechatAuthError::MissingWechatIdentity => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
|
||||
}
|
||||
WechatAuthError::UserNotFound => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
|
||||
}
|
||||
WechatAuthError::Store(_) | WechatAuthError::PasswordHash(_) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_wechat_bind_phone_error(error: module_auth::PhoneAuthError) -> AppError {
|
||||
match error {
|
||||
module_auth::PhoneAuthError::InvalidPhoneNumber
|
||||
| module_auth::PhoneAuthError::InvalidVerifyCode
|
||||
| module_auth::PhoneAuthError::VerifyCodeNotFound
|
||||
| module_auth::PhoneAuthError::VerifyCodeExpired
|
||||
| module_auth::PhoneAuthError::UserStateMismatch => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
|
||||
}
|
||||
module_auth::PhoneAuthError::SendCoolingDown {
|
||||
retry_after_seconds,
|
||||
} => {
|
||||
let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
|
||||
.with_message(error.to_string())
|
||||
.with_details(serde_json::json!({ "retryAfterSeconds": retry_after_seconds }));
|
||||
match HeaderValue::from_str(&retry_after_seconds.to_string()) {
|
||||
Ok(value) => app_error.with_header("retry-after", value),
|
||||
Err(_) => app_error,
|
||||
}
|
||||
}
|
||||
module_auth::PhoneAuthError::VerifyAttemptsExceeded => {
|
||||
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string())
|
||||
}
|
||||
module_auth::PhoneAuthError::UserNotFound => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
|
||||
}
|
||||
module_auth::PhoneAuthError::Store(_) | module_auth::PhoneAuthError::PasswordHash(_) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
280
server-rs/crates/api-server/src/wechat_provider.rs
Normal file
280
server-rs/crates/api-server/src/wechat_provider.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
use module_auth::{WechatAuthScene, WechatIdentityProfile};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
use crate::{config::AppConfig, http_error::AppError};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WechatProvider {
|
||||
Disabled,
|
||||
Mock(MockWechatProvider),
|
||||
Real(RealWechatProvider),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MockWechatProvider {
|
||||
mock_user_id: String,
|
||||
mock_union_id: Option<String>,
|
||||
mock_display_name: String,
|
||||
mock_avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RealWechatProvider {
|
||||
client: Client,
|
||||
app_id: String,
|
||||
app_secret: String,
|
||||
authorize_endpoint: String,
|
||||
access_token_endpoint: String,
|
||||
user_info_endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatAccessTokenResponse {
|
||||
access_token: Option<String>,
|
||||
openid: Option<String>,
|
||||
unionid: Option<String>,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatUserInfoResponse {
|
||||
openid: Option<String>,
|
||||
unionid: Option<String>,
|
||||
nickname: Option<String>,
|
||||
headimgurl: Option<String>,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider {
|
||||
if !config.wechat_auth_enabled {
|
||||
return WechatProvider::Disabled;
|
||||
}
|
||||
|
||||
if config
|
||||
.wechat_auth_provider
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("mock")
|
||||
{
|
||||
return WechatProvider::Mock(MockWechatProvider {
|
||||
mock_user_id: config.wechat_mock_user_id.clone(),
|
||||
mock_union_id: config.wechat_mock_union_id.clone(),
|
||||
mock_display_name: config.wechat_mock_display_name.clone(),
|
||||
mock_avatar_url: config.wechat_mock_avatar_url.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let Some(app_id) = config.wechat_app_id.clone() else {
|
||||
return WechatProvider::Disabled;
|
||||
};
|
||||
let Some(app_secret) = config.wechat_app_secret.clone() else {
|
||||
return WechatProvider::Disabled;
|
||||
};
|
||||
|
||||
WechatProvider::Real(RealWechatProvider {
|
||||
client: Client::new(),
|
||||
app_id,
|
||||
app_secret,
|
||||
authorize_endpoint: config.wechat_authorize_endpoint.clone(),
|
||||
access_token_endpoint: config.wechat_access_token_endpoint.clone(),
|
||||
user_info_endpoint: config.wechat_user_info_endpoint.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
impl WechatProvider {
|
||||
pub fn build_authorization_url(
|
||||
&self,
|
||||
callback_url: &str,
|
||||
state: &str,
|
||||
scene: &WechatAuthScene,
|
||||
) -> Result<String, AppError> {
|
||||
match self {
|
||||
Self::Disabled => {
|
||||
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"))
|
||||
}
|
||||
Self::Mock(_) => {
|
||||
let mut callback = Url::parse(callback_url).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("微信回调地址非法:{error}"))
|
||||
})?;
|
||||
callback
|
||||
.query_pairs_mut()
|
||||
.append_pair("mock_code", "wx-mock-code")
|
||||
.append_pair("state", state);
|
||||
Ok(callback.to_string())
|
||||
}
|
||||
Self::Real(provider) => provider.build_authorization_url(callback_url, state, scene),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_callback_profile(
|
||||
&self,
|
||||
code: Option<&str>,
|
||||
mock_code: Option<&str>,
|
||||
) -> Result<WechatIdentityProfile, AppError> {
|
||||
match self {
|
||||
Self::Disabled => {
|
||||
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"))
|
||||
}
|
||||
Self::Mock(provider) => Ok(provider.resolve_callback_profile(mock_code)),
|
||||
Self::Real(provider) => provider.resolve_callback_profile(code).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MockWechatProvider {
|
||||
fn resolve_callback_profile(&self, mock_code: Option<&str>) -> WechatIdentityProfile {
|
||||
let provider_uid = mock_code
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(self.mock_user_id.as_str())
|
||||
.to_string();
|
||||
WechatIdentityProfile {
|
||||
provider_uid,
|
||||
provider_union_id: self.mock_union_id.clone(),
|
||||
display_name: Some(self.mock_display_name.clone()),
|
||||
avatar_url: self.mock_avatar_url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RealWechatProvider {
|
||||
fn build_authorization_url(
|
||||
&self,
|
||||
callback_url: &str,
|
||||
state: &str,
|
||||
scene: &WechatAuthScene,
|
||||
) -> Result<String, AppError> {
|
||||
let mut url = Url::parse(match scene {
|
||||
WechatAuthScene::Desktop => &self.authorize_endpoint,
|
||||
WechatAuthScene::WechatInApp => "https://open.weixin.qq.com/connect/oauth2/authorize",
|
||||
})
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("微信授权地址非法:{error}"))
|
||||
})?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("appid", &self.app_id)
|
||||
.append_pair("redirect_uri", callback_url)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair(
|
||||
"scope",
|
||||
match scene {
|
||||
WechatAuthScene::Desktop => "snsapi_login",
|
||||
WechatAuthScene::WechatInApp => "snsapi_userinfo",
|
||||
},
|
||||
)
|
||||
.append_pair("state", state);
|
||||
Ok(format!("{url}#wechat_redirect"))
|
||||
}
|
||||
|
||||
async fn resolve_callback_profile(
|
||||
&self,
|
||||
code: Option<&str>,
|
||||
) -> Result<WechatIdentityProfile, AppError> {
|
||||
let code = code
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少微信授权 code")
|
||||
})?;
|
||||
|
||||
let mut access_token_url = Url::parse(&self.access_token_endpoint).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("微信 access_token 地址非法:{error}"))
|
||||
})?;
|
||||
access_token_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("appid", &self.app_id)
|
||||
.append_pair("secret", &self.app_secret)
|
||||
.append_pair("code", code)
|
||||
.append_pair("grant_type", "authorization_code");
|
||||
|
||||
let access_token_payload = self
|
||||
.client
|
||||
.get(access_token_url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信 access_token 请求失败");
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("微信登录失败:access_token 请求失败")
|
||||
})?
|
||||
.json::<WechatAccessTokenResponse>()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信 access_token 响应解析失败");
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("微信登录失败:access_token 响应非法")
|
||||
})?;
|
||||
|
||||
let access_token = access_token_payload
|
||||
.access_token
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!(
|
||||
"微信登录失败:{}",
|
||||
access_token_payload
|
||||
.errmsg
|
||||
.unwrap_or_else(|| "缺少 access_token".to_string())
|
||||
))
|
||||
})?;
|
||||
let openid = access_token_payload
|
||||
.openid
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("微信登录失败:缺少 openid")
|
||||
})?;
|
||||
|
||||
let mut user_info_url = Url::parse(&self.user_info_endpoint).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("微信用户信息地址非法:{error}"))
|
||||
})?;
|
||||
user_info_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("access_token", &access_token)
|
||||
.append_pair("openid", &openid)
|
||||
.append_pair("lang", "zh_CN");
|
||||
|
||||
let user_info_payload = self
|
||||
.client
|
||||
.get(user_info_url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信用户信息请求失败");
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("微信登录失败:用户信息请求失败")
|
||||
})?
|
||||
.json::<WechatUserInfoResponse>()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信用户信息响应解析失败");
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("微信登录失败:用户信息响应非法")
|
||||
})?;
|
||||
|
||||
let provider_uid = user_info_payload
|
||||
.openid
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!(
|
||||
"微信登录失败:{}",
|
||||
user_info_payload
|
||||
.errmsg
|
||||
.unwrap_or_else(|| "缺少 openid".to_string())
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(WechatIdentityProfile {
|
||||
provider_uid,
|
||||
provider_union_id: user_info_payload.unionid.or(access_token_payload.unionid),
|
||||
display_name: user_info_payload.nickname,
|
||||
avatar_url: user_info_payload.headimgurl,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user