Merge branch 'hermes/wechat'
# Conflicts: # .hermes/shared-memory/decision-log.md # docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md # docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md # server-rs/crates/module-runtime/src/errors.rs # src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx # src/components/rpg-entry/RpgEntryHomeView.tsx
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user