feat: gate recharge payment by login device

This commit is contained in:
2026-05-15 08:43:21 +08:00
parent 5b70ec6af7
commit c94f22e26c
12 changed files with 527 additions and 72 deletions

View File

@@ -1,19 +1,19 @@
use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE};
use module_auth::{
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionError,
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionClientInfo,
RefreshSessionError,
};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
AccessTokenClaims, AccessTokenClaimsInput, AccessTokenDeviceInfo, AuthProvider, BindingStatus,
build_refresh_session_clear_cookie, build_refresh_session_set_cookie,
create_refresh_session_token, hash_refresh_session_token, sign_access_token,
};
use time::OffsetDateTime;
use crate::session_client::SessionClientContext;
use crate::{
http_error::AppError, request_context::RequestContext, state::AppState,
tracking::record_daily_login_tracking_event_after_success as record_daily_login_tracking_event_via_unified_path,
};
#[cfg(not(test))]
use crate::tracking::record_daily_login_tracking_event_after_success as record_daily_login_tracking_event_via_unified_path;
use crate::{http_error::AppError, request_context::RequestContext, state::AppState};
#[derive(Debug, Clone)]
pub struct SignedAuthSession {
@@ -81,6 +81,7 @@ pub fn create_auth_session(
user,
&session.session.session_id,
Some(&session_provider),
Some(&session.session.client_info),
)?;
Ok(SignedAuthSession {
@@ -94,8 +95,9 @@ pub fn sign_access_token_for_user(
user: &AuthUser,
session_id: &str,
session_provider_override: Option<&AuthLoginMethod>,
client_info: Option<&RefreshSessionClientInfo>,
) -> Result<String, AppError> {
let access_claims = AccessTokenClaims::from_input(
let access_claims = AccessTokenClaims::from_input_with_device(
AccessTokenClaimsInput {
user_id: user.id.clone(),
session_id: session_id.to_string(),
@@ -106,6 +108,7 @@ pub fn sign_access_token_for_user(
binding_status: map_binding_status(&user.binding_status),
display_name: Some(user.display_name.clone()),
},
client_info.map(map_access_token_device_info),
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
@@ -182,3 +185,11 @@ fn map_binding_status(binding_status: &module_auth::AuthBindingStatus) -> Bindin
module_auth::AuthBindingStatus::PendingBindPhone => BindingStatus::PendingBindPhone,
}
}
fn map_access_token_device_info(client_info: &RefreshSessionClientInfo) -> AccessTokenDeviceInfo {
AccessTokenDeviceInfo {
client_type: client_info.client_type.clone(),
client_runtime: client_info.client_runtime.clone(),
client_platform: client_info.client_platform.clone(),
}
}

View File

@@ -54,6 +54,7 @@ pub async fn refresh_session(
&rotated.user,
&rotated.session.session_id,
Some(&rotated.session.issued_by_provider),
Some(&rotated.session.client_info),
)?;
record_daily_login_tracking_event_after_auth_success(
&state,

View File

@@ -197,6 +197,8 @@ pub async fn create_profile_recharge_order(
let user_id = authenticated.claims().user_id().to_string();
let payment_channel = normalize_recharge_payment_channel(payload.payment_channel)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
validate_recharge_device_for_payment_channel(authenticated.claims(), &payment_channel)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
validate_recharge_payment_channel(&state, &payment_channel)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let created_at_micros = current_unix_micros();
@@ -956,6 +958,34 @@ fn validate_recharge_payment_channel(
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道无效"))
}
fn validate_recharge_device_for_payment_channel(
claims: &platform_auth::AccessTokenClaims,
payment_channel: &str,
) -> Result<(), AppError> {
if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK {
return Ok(());
}
if !is_wechat_recharge_payment_channel(payment_channel) {
return Ok(());
}
let is_supported_device = match payment_channel {
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => {
claims.is_wechat_mini_program_device()
}
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => claims.is_mobile_wechat_browser_device(),
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => claims.is_desktop_wechat_browser_device(),
_ => false,
};
if is_supported_device {
return Ok(());
}
Err(AppError::from_status(StatusCode::FORBIDDEN)
.with_message("当前登录设备不支持充值,请在微信环境内登录后重试"))
}
fn validate_real_wechat_recharge_payment_provider(state: &AppState) -> Result<(), AppError> {
if !state.config.wechat_pay_enabled {
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
@@ -1738,7 +1768,7 @@ mod tests {
}
#[tokio::test]
async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() {
async fn profile_recharge_order_rejects_non_wechat_device_before_spacetime() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
@@ -1749,6 +1779,133 @@ mod tests {
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"当前登录设备不支持充值,请在微信环境内登录后重试"
);
}
#[tokio::test]
async fn profile_recharge_order_rejects_mismatched_wechat_channel_before_spacetime() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token = issue_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_mobile_h5");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"当前登录设备不支持充值,请在微信环境内登录后重试"
);
}
#[tokio::test]
async fn profile_recharge_order_allows_desktop_wechat_native_channel_before_provider_check() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token =
issue_wechat_h5_access_token(&state, "windows", "sess_runtime_profile_desktop_wechat");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"真实微信支付渠道不能使用 mock 支付配置"
);
}
#[tokio::test]
async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token = issue_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_wechat_h5");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
@@ -2043,4 +2200,34 @@ mod tests {
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
fn issue_wechat_h5_access_token(
state: &AppState,
client_platform: &str,
session_id: &str,
) -> String {
let claims = AccessTokenClaims::from_input_with_device(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", session_id),
provider: AuthProvider::Wechat,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("微信资料页用户".to_string()),
},
Some(platform_auth::AccessTokenDeviceInfo {
client_type: "wechat_h5".to_string(),
client_runtime: "wechat_embedded_browser".to_string(),
client_platform: client_platform.to_string(),
}),
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}