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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
本阶段已经完成 JWT 基础能力首版落地:
|
||||
|
||||
1. 新增 `JwtConfig`,统一管理 `issuer`、`secret` 与 access token TTL。
|
||||
2. 新增 `AccessTokenClaimsInput` 与 `AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name` 映射到 Rust 结构。
|
||||
2. 新增 `AccessTokenClaimsInput` 与 `AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name/device` 映射到 Rust 结构。
|
||||
3. 新增 `sign_access_token(...)`,按 `HS256` 签发 access token。
|
||||
4. 新增 `verify_access_token(...)`,统一校验 `iss/sub/exp/iat` 与 JWT 签名。
|
||||
5. 新增 `RefreshCookieConfig`、`RefreshCookieSameSite` 与 `read_refresh_session_token(...)`,统一 refresh cookie 读取口径。
|
||||
@@ -36,13 +36,14 @@
|
||||
|
||||
1. `JwtConfig::new(...)`
|
||||
2. `AccessTokenClaims::from_input(...)`
|
||||
3. `sign_access_token(...)`
|
||||
4. `verify_access_token(...)`
|
||||
5. `RefreshCookieConfig::new(...)`
|
||||
6. `read_refresh_session_token(...)`
|
||||
7. `AuthProvider`
|
||||
8. `BindingStatus`
|
||||
9. `RefreshCookieSameSite`
|
||||
3. `AccessTokenClaims::from_input_with_device(...)`
|
||||
4. `sign_access_token(...)`
|
||||
5. `verify_access_token(...)`
|
||||
6. `RefreshCookieConfig::new(...)`
|
||||
7. `read_refresh_session_token(...)`
|
||||
8. `AuthProvider`
|
||||
9. `BindingStatus`
|
||||
10. `RefreshCookieSameSite`
|
||||
|
||||
## 4. 配置口径
|
||||
|
||||
@@ -67,7 +68,7 @@
|
||||
|
||||
1. `platform-auth` 只承接平台适配,不承接 `module-auth` 的业务规则和状态真相。
|
||||
2. `sub` 必须是稳定 `user_id`,`sid` 必须是会话 ID,不能退化为一次 token 的随机 ID。
|
||||
3. 不允许把手机号、openid、refresh token hash、风控状态等敏感或高频变化字段塞进 JWT。
|
||||
3. 不允许把手机号、openid、refresh token hash、风控状态、完整设备对象、IP、UA 等敏感或高频变化字段塞进 JWT;`device` 只允许保存支付拦截需要的最小设备快照。
|
||||
4. 鉴权状态最终由 `module-auth` 与 `crates/spacetime-module` 管理,前端接口由 `crates/api-server` 暴露。
|
||||
5. 不允许把短信、微信、Cookie、JWT 等外部细节重新散落到多个业务模块中各自实现。
|
||||
|
||||
|
||||
@@ -66,6 +66,15 @@ pub enum BindingStatus {
|
||||
PendingBindPhone,
|
||||
}
|
||||
|
||||
// JWT 里只保留一份规范化后的设备快照,用于后端按登录设备拦截充值路径。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct AccessTokenDeviceInfo {
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
}
|
||||
|
||||
// 用于签发 access token 的领域输入,和最终 JWT claims 解耦,避免业务层手动拼 iat/exp/iss。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AccessTokenClaimsInput {
|
||||
@@ -92,6 +101,8 @@ pub struct AccessTokenClaims {
|
||||
pub binding_status: BindingStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub device: Option<AccessTokenDeviceInfo>,
|
||||
pub iat: u64,
|
||||
pub exp: u64,
|
||||
}
|
||||
@@ -1481,11 +1492,21 @@ impl AccessTokenClaims {
|
||||
input: AccessTokenClaimsInput,
|
||||
config: &JwtConfig,
|
||||
issued_at: OffsetDateTime,
|
||||
) -> Result<Self, JwtError> {
|
||||
Self::from_input_with_device(input, None, config, issued_at)
|
||||
}
|
||||
|
||||
pub fn from_input_with_device(
|
||||
input: AccessTokenClaimsInput,
|
||||
device: Option<AccessTokenDeviceInfo>,
|
||||
config: &JwtConfig,
|
||||
issued_at: OffsetDateTime,
|
||||
) -> Result<Self, JwtError> {
|
||||
let user_id = normalize_required_field(input.user_id, "JWT sub 不能为空")?;
|
||||
let session_id = normalize_required_field(input.session_id, "JWT sid 不能为空")?;
|
||||
let roles = normalize_roles(input.roles)?;
|
||||
let display_name = normalize_optional_field(input.display_name);
|
||||
let device = device.map(|device| device.normalize()).transpose()?;
|
||||
|
||||
let issued_at_unix = issued_at.unix_timestamp();
|
||||
if issued_at_unix < 0 {
|
||||
@@ -1515,6 +1536,7 @@ impl AccessTokenClaims {
|
||||
phone_verified: input.phone_verified,
|
||||
binding_status: input.binding_status,
|
||||
display_name,
|
||||
device,
|
||||
iat: issued_at_unix as u64,
|
||||
exp: expires_at_unix as u64,
|
||||
};
|
||||
@@ -1535,6 +1557,46 @@ impl AccessTokenClaims {
|
||||
self.ver
|
||||
}
|
||||
|
||||
pub fn client_type(&self) -> Option<&str> {
|
||||
self.device
|
||||
.as_ref()
|
||||
.map(|device| device.client_type.as_str())
|
||||
}
|
||||
|
||||
pub fn client_platform(&self) -> Option<&str> {
|
||||
self.device
|
||||
.as_ref()
|
||||
.map(|device| device.client_platform.as_str())
|
||||
}
|
||||
|
||||
pub fn is_wechat_mini_program_device(&self) -> bool {
|
||||
matches!(self.client_type(), Some("mini_program"))
|
||||
}
|
||||
|
||||
pub fn is_wechat_h5_device(&self) -> bool {
|
||||
matches!(self.client_type(), Some("wechat_h5"))
|
||||
}
|
||||
|
||||
pub fn is_wechat_payment_device(&self) -> bool {
|
||||
self.is_wechat_mini_program_device() || self.is_wechat_h5_device()
|
||||
}
|
||||
|
||||
pub fn is_mobile_device(&self) -> bool {
|
||||
matches!(self.client_platform(), Some("ios" | "android"))
|
||||
}
|
||||
|
||||
pub fn is_desktop_device(&self) -> bool {
|
||||
matches!(self.client_platform(), Some("windows" | "macos" | "linux"))
|
||||
}
|
||||
|
||||
pub fn is_mobile_wechat_browser_device(&self) -> bool {
|
||||
self.is_wechat_h5_device() && self.is_mobile_device()
|
||||
}
|
||||
|
||||
pub fn is_desktop_wechat_browser_device(&self) -> bool {
|
||||
self.is_wechat_h5_device() && self.is_desktop_device()
|
||||
}
|
||||
|
||||
pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> {
|
||||
if self.iss.trim() != config.issuer() {
|
||||
return Err(JwtError::InvalidClaims("JWT iss 与当前配置不一致"));
|
||||
@@ -1543,6 +1605,9 @@ impl AccessTokenClaims {
|
||||
normalize_required_field(self.sub.clone(), "JWT sub 不能为空")?;
|
||||
normalize_required_field(self.sid.clone(), "JWT sid 不能为空")?;
|
||||
normalize_roles(self.roles.clone())?;
|
||||
if let Some(device) = &self.device {
|
||||
device.validate()?;
|
||||
}
|
||||
|
||||
if self.exp <= self.iat {
|
||||
return Err(JwtError::InvalidClaims("JWT exp 必须晚于 iat"));
|
||||
@@ -1552,6 +1617,38 @@ impl AccessTokenClaims {
|
||||
}
|
||||
}
|
||||
|
||||
impl AccessTokenDeviceInfo {
|
||||
pub fn normalize(self) -> Result<Self, JwtError> {
|
||||
Ok(Self {
|
||||
client_type: normalize_required_field(
|
||||
self.client_type,
|
||||
"JWT device.client_type 不能为空",
|
||||
)?,
|
||||
client_runtime: normalize_required_field(
|
||||
self.client_runtime,
|
||||
"JWT device.client_runtime 不能为空",
|
||||
)?,
|
||||
client_platform: normalize_required_field(
|
||||
self.client_platform,
|
||||
"JWT device.client_platform 不能为空",
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), JwtError> {
|
||||
normalize_required_field(self.client_type.clone(), "JWT device.client_type 不能为空")?;
|
||||
normalize_required_field(
|
||||
self.client_runtime.clone(),
|
||||
"JWT device.client_runtime 不能为空",
|
||||
)?;
|
||||
normalize_required_field(
|
||||
self.client_platform.clone(),
|
||||
"JWT device.client_platform 不能为空",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sign_access_token(
|
||||
claims: &AccessTokenClaims,
|
||||
config: &JwtConfig,
|
||||
@@ -2129,10 +2226,7 @@ mod tests {
|
||||
let phone_info = payload.phone_info.expect("phone info should exist");
|
||||
|
||||
assert_eq!(phone_info.phone_number.as_deref(), Some("+8613800138000"));
|
||||
assert_eq!(
|
||||
phone_info.pure_phone_number.as_deref(),
|
||||
Some("13800138000")
|
||||
);
|
||||
assert_eq!(phone_info.pure_phone_number.as_deref(), Some("13800138000"));
|
||||
assert_eq!(phone_info.country_code.as_deref(), Some("86"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user