feat: gate recharge payment by login device
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user