AGC认证错误改为顶层类别加类型化reason
- auth_error.rs:顶层从拍平变体收敛为类别变体,服务地址/网络失败/响应契约破损各占一个变体,不再拆成 7+2+5 个 - auth_error.rs:新增 ServerAddressReason、AuthNetworkReason、AuthResponseInvalidReason 三个 ts-rs 枚举,payload 改持类型化 reason - auth_session.rs:校验与映射改用 server_address_rejected / response_invalid 收口,按类型化 reason 构造
This commit is contained in:
@@ -2,19 +2,13 @@
|
||||
//!
|
||||
//! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。
|
||||
//!
|
||||
//! 变体一律拍平在同一层,不嵌套子枚举:可枚举的事实各自成变体(例如服务地址校验的 7 种失败、
|
||||
//! 网络超时 / 不可达、响应契约的 5 种破损),类型本身说不出来的事实才进载荷(服务端原文、HTTP
|
||||
//! 状态码、本机 IO 明细)。internally tagged 下无字段变体序列化成 `{ "type": "x" }`,带载荷的
|
||||
//! 是 `{ "type": "x", ... }`;ts-rs 生成 `{ type: 'x' }` 或 `{ type: 'x' } & X`,前端每个分支
|
||||
//! 拿到的形状与 Rust 一致。
|
||||
//! 顶层只放**调用方要分流的类别**;同一类里可枚举的细分原因收进**类型化 `reason` 字段**,
|
||||
//! 而不是各拆一个变体,也不是字符串。例如服务地址校验的 7 种失败、网络失败的超时/不可达、
|
||||
//! 响应契约的 5 种破损各自只占一个变体:前端 `switch (failure.type)` 选到类别后,再用
|
||||
//! `switch (payload.reason)` 在**类型化**的细分上分流,仍然不碰文案。
|
||||
//!
|
||||
//! Rust **不预拼用户可见文案**:载荷只存原始事实(服务端 400 的原文、本机 IO 的 `detail`),
|
||||
//! 前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。
|
||||
//!
|
||||
//! 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(平台 `AppError.code` 仍是
|
||||
//! 通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(例如
|
||||
//! [`PasswordLoginRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 `401/403` 是
|
||||
//! "登录态失效",登录路由的 `401` 是用户可修正的输入问题,这个区分由变体承担。
|
||||
//! Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文、HTTP 状态码、本机 IO 的
|
||||
//! `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。
|
||||
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
@@ -25,21 +19,9 @@ use ts_rs::TS;
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
|
||||
pub(crate) enum ClientAuthError {
|
||||
// ---- 业务:用户自己能改,调用方给提示,不上报 ----
|
||||
/// 服务地址为空或超长。
|
||||
ServerAddressEmptyOrTooLong,
|
||||
/// 服务地址不是合法 URL。
|
||||
ServerAddressNotAUrl,
|
||||
/// 服务地址带用户名 / 密码。
|
||||
ServerAddressHasCredentials,
|
||||
/// 服务地址带路径、查询或 fragment。
|
||||
ServerAddressHasPathOrQueryOrFragment,
|
||||
/// 非本机服务地址不是 HTTPS。
|
||||
ServerAddressNotHttps,
|
||||
/// 服务地址 scheme 不是 http(s)。
|
||||
ServerAddressUnsupportedScheme,
|
||||
/// 发布构建里服务地址不在当前构建渠道范围内。
|
||||
ServerAddressOutsideChannel,
|
||||
/// 本地前置校验:手机号为空、超长或不是纯数字。
|
||||
/// 服务地址校验失败(含渠道范围门禁),细分原因见 [`ServerAddressReason`]。
|
||||
ServerAddressRejected(ServerAddressRejected),
|
||||
/// 本地前置校验:手机号为空、超长或不是纯数字(校验器只回 bool,没有更细的事实)。
|
||||
PhoneNumberInvalid,
|
||||
/// 本地前置校验:密码为空。
|
||||
PasswordMissing,
|
||||
@@ -65,24 +47,14 @@ pub(crate) enum ClientAuthError {
|
||||
PermissionDenied,
|
||||
|
||||
// ---- 系统:调用方处理不了,带上下文重抛 ----
|
||||
/// 连接登录服务超时。
|
||||
AuthNetworkTimeout,
|
||||
/// 连接登录服务失败(DNS / 连接被拒等)。
|
||||
AuthNetworkUnreachable,
|
||||
/// 连接登录服务的传输层失败,细分原因见 [`AuthNetworkReason`]。
|
||||
AuthNetworkFailure(AuthNetworkFailure),
|
||||
/// 登录服务 5xx。
|
||||
AuthServiceUnavailable(AuthServiceUnavailable),
|
||||
/// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。
|
||||
UnexpectedRejection(UnexpectedRejection),
|
||||
/// 登录服务响应不是合法 JSON。
|
||||
AuthResponseNotJson,
|
||||
/// 登录服务响应不是预期结构(缺字段 / 类型不符)。
|
||||
AuthResponseInvalidBody,
|
||||
/// 登录 / 续期响应没有下发新的续期凭据。
|
||||
AuthResponseMissingRefreshCookie,
|
||||
/// 登录响应没有带上会话主体(用户身份)。
|
||||
AuthResponseMissingUserIdentity,
|
||||
/// 登录服务在响应体里显式拒绝(`ok: false`)。
|
||||
AuthResponseServerRejected(AuthResponseServerRejected),
|
||||
/// 登录服务响应的契约破损,细分原因见 [`AuthResponseInvalidReason`]。
|
||||
AuthResponseInvalid(AuthResponseInvalid),
|
||||
/// 本机登录凭据文件读写失败。
|
||||
ClientSessionPersistFailed(ClientSessionPersistFailed),
|
||||
/// 本机运行时会话安装 / 清理失败。
|
||||
@@ -91,8 +63,67 @@ pub(crate) enum ClientAuthError {
|
||||
AuthClientInitFailed,
|
||||
}
|
||||
|
||||
// ---- 可枚举的细分原因:类型化字段,不是字符串 ----
|
||||
|
||||
/// 服务地址校验失败的具体原因。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
|
||||
pub(crate) enum ServerAddressReason {
|
||||
/// 为空或超长。
|
||||
EmptyOrTooLong,
|
||||
/// 不是合法 URL。
|
||||
NotAUrl,
|
||||
/// 带用户名 / 密码。
|
||||
HasCredentials,
|
||||
/// 带路径、查询或 fragment。
|
||||
HasPathOrQueryOrFragment,
|
||||
/// 非本机地址不是 HTTPS。
|
||||
NotHttps,
|
||||
/// scheme 不是 http(s)。
|
||||
UnsupportedScheme,
|
||||
/// 发布构建里不在当前构建渠道范围内。
|
||||
OutsideChannel,
|
||||
}
|
||||
|
||||
/// 连接登录服务失败的具体原因。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
|
||||
pub(crate) enum AuthNetworkReason {
|
||||
/// 超时。
|
||||
Timeout,
|
||||
/// DNS / 连接被拒 / 读响应失败等。
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
/// 登录响应契约破损的具体原因。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
|
||||
pub(crate) enum AuthResponseInvalidReason {
|
||||
/// 不是合法 JSON。
|
||||
NotJson,
|
||||
/// 不是预期结构(缺字段 / 类型不符 / 凭据格式无效)。
|
||||
InvalidBody,
|
||||
/// 没有下发新的续期凭据。
|
||||
MissingRefreshCookie,
|
||||
/// 没有带上会话主体(用户身份)。
|
||||
MissingUserIdentity,
|
||||
/// 响应体里显式拒绝(`ok: false`)。
|
||||
ServerRejected,
|
||||
}
|
||||
|
||||
// ---- 载荷:只装类型说不出来的事实 ----
|
||||
|
||||
/// 服务地址被拒的具体原因。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
|
||||
pub(crate) struct ServerAddressRejected {
|
||||
pub(crate) reason: ServerAddressReason,
|
||||
}
|
||||
|
||||
/// `/api/auth/entry` 返回 400 时服务端给的原文。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -120,8 +151,16 @@ pub(crate) struct PhoneCodeLoginRejected {
|
||||
pub(crate) server_message: Option<String>,
|
||||
}
|
||||
|
||||
/// 连接登录服务的传输层失败原因。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
|
||||
pub(crate) struct AuthNetworkFailure {
|
||||
pub(crate) reason: AuthNetworkReason,
|
||||
}
|
||||
|
||||
/// 登录服务 5xx 的状态码。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
|
||||
pub(crate) struct AuthServiceUnavailable {
|
||||
@@ -137,11 +176,13 @@ pub(crate) struct UnexpectedRejection {
|
||||
pub(crate) server_message: Option<String>,
|
||||
}
|
||||
|
||||
/// 登录服务在响应体里显式拒绝时给的原文。
|
||||
/// 登录响应契约破损的原因与服务端原文。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
|
||||
pub(crate) struct AuthResponseServerRejected {
|
||||
pub(crate) struct AuthResponseInvalid {
|
||||
pub(crate) reason: AuthResponseInvalidReason,
|
||||
/// 只有 [`AuthResponseInvalidReason::ServerRejected`] 可能带服务端原文;其余是 `None`。
|
||||
pub(crate) server_message: Option<String>,
|
||||
}
|
||||
|
||||
@@ -177,32 +218,10 @@ mod tests {
|
||||
fn wire_variant_names_are_the_frontend_dispatch_keys() {
|
||||
let cases = [
|
||||
(
|
||||
ClientAuthError::ServerAddressEmptyOrTooLong,
|
||||
"serverAddressEmptyOrTooLong",
|
||||
),
|
||||
(
|
||||
ClientAuthError::ServerAddressNotAUrl,
|
||||
"serverAddressNotAUrl",
|
||||
),
|
||||
(
|
||||
ClientAuthError::ServerAddressHasCredentials,
|
||||
"serverAddressHasCredentials",
|
||||
),
|
||||
(
|
||||
ClientAuthError::ServerAddressHasPathOrQueryOrFragment,
|
||||
"serverAddressHasPathOrQueryOrFragment",
|
||||
),
|
||||
(
|
||||
ClientAuthError::ServerAddressNotHttps,
|
||||
"serverAddressNotHttps",
|
||||
),
|
||||
(
|
||||
ClientAuthError::ServerAddressUnsupportedScheme,
|
||||
"serverAddressUnsupportedScheme",
|
||||
),
|
||||
(
|
||||
ClientAuthError::ServerAddressOutsideChannel,
|
||||
"serverAddressOutsideChannel",
|
||||
ClientAuthError::ServerAddressRejected(ServerAddressRejected {
|
||||
reason: ServerAddressReason::NotHttps,
|
||||
}),
|
||||
"serverAddressRejected",
|
||||
),
|
||||
(ClientAuthError::PhoneNumberInvalid, "phoneNumberInvalid"),
|
||||
(ClientAuthError::PasswordMissing, "passwordMissing"),
|
||||
@@ -233,10 +252,11 @@ mod tests {
|
||||
(ClientAuthError::SmsCodeRejected, "smsCodeRejected"),
|
||||
(ClientAuthError::SessionInvalidated, "sessionInvalidated"),
|
||||
(ClientAuthError::PermissionDenied, "permissionDenied"),
|
||||
(ClientAuthError::AuthNetworkTimeout, "authNetworkTimeout"),
|
||||
(
|
||||
ClientAuthError::AuthNetworkUnreachable,
|
||||
"authNetworkUnreachable",
|
||||
ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
|
||||
reason: AuthNetworkReason::Timeout,
|
||||
}),
|
||||
"authNetworkFailure",
|
||||
),
|
||||
(
|
||||
ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }),
|
||||
@@ -249,24 +269,12 @@ mod tests {
|
||||
}),
|
||||
"unexpectedRejection",
|
||||
),
|
||||
(ClientAuthError::AuthResponseNotJson, "authResponseNotJson"),
|
||||
(
|
||||
ClientAuthError::AuthResponseInvalidBody,
|
||||
"authResponseInvalidBody",
|
||||
),
|
||||
(
|
||||
ClientAuthError::AuthResponseMissingRefreshCookie,
|
||||
"authResponseMissingRefreshCookie",
|
||||
),
|
||||
(
|
||||
ClientAuthError::AuthResponseMissingUserIdentity,
|
||||
"authResponseMissingUserIdentity",
|
||||
),
|
||||
(
|
||||
ClientAuthError::AuthResponseServerRejected(AuthResponseServerRejected {
|
||||
ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
|
||||
reason: AuthResponseInvalidReason::ServerRejected,
|
||||
server_message: None,
|
||||
}),
|
||||
"authResponseServerRejected",
|
||||
"authResponseInvalid",
|
||||
),
|
||||
(
|
||||
ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed {
|
||||
@@ -301,6 +309,25 @@ mod tests {
|
||||
assert_eq!(value, serde_json::json!({ "type": "loginCodeMissing" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reason_fields_are_typed_enums_not_strings() {
|
||||
let rejected = serde_json::to_value(ClientAuthError::ServerAddressRejected(
|
||||
ServerAddressRejected {
|
||||
reason: ServerAddressReason::HasPathOrQueryOrFragment,
|
||||
},
|
||||
))
|
||||
.expect("serialize auth error");
|
||||
assert_eq!(rejected["type"], "serverAddressRejected");
|
||||
assert_eq!(rejected["reason"], "hasPathOrQueryOrFragment");
|
||||
|
||||
let network =
|
||||
serde_json::to_value(ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
|
||||
reason: AuthNetworkReason::Unreachable,
|
||||
}))
|
||||
.expect("serialize auth error");
|
||||
assert_eq!(network["reason"], "unreachable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_variants_keep_machine_facts_and_server_text() {
|
||||
let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable(
|
||||
@@ -319,6 +346,15 @@ mod tests {
|
||||
assert_eq!(rejection["status"], 409);
|
||||
assert_eq!(rejection["serverMessage"], "冲突");
|
||||
|
||||
let invalid =
|
||||
serde_json::to_value(ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
|
||||
reason: AuthResponseInvalidReason::ServerRejected,
|
||||
server_message: Some("登录服务请求失败".to_string()),
|
||||
}))
|
||||
.expect("serialize auth error");
|
||||
assert_eq!(invalid["reason"], "serverRejected");
|
||||
assert_eq!(invalid["serverMessage"], "登录服务请求失败");
|
||||
|
||||
let persist = serde_json::to_value(ClientAuthError::ClientSessionPersistFailed(
|
||||
ClientSessionPersistFailed {
|
||||
detail: "磁盘只读".to_string(),
|
||||
@@ -333,6 +369,9 @@ mod tests {
|
||||
assert!(ClientAuthError::SessionInvalidated.is_authority_failure());
|
||||
assert!(ClientAuthError::PermissionDenied.is_authority_failure());
|
||||
assert!(!ClientAuthError::PhoneOrPasswordMismatch.is_authority_failure());
|
||||
assert!(!ClientAuthError::AuthNetworkTimeout.is_authority_failure());
|
||||
assert!(!ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
|
||||
reason: AuthNetworkReason::Timeout,
|
||||
})
|
||||
.is_authority_failure());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
//! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。
|
||||
|
||||
use crate::auth_error::{
|
||||
AuthResponseServerRejected, AuthServiceUnavailable, ClientAuthError,
|
||||
ClientSessionPersistFailed, PasswordLoginRejected, PhoneCodeLoginRejected,
|
||||
RuntimeSessionInstallFailed, SendCodeRejected, UnexpectedRejection,
|
||||
AuthNetworkFailure, AuthNetworkReason, AuthResponseInvalid, AuthResponseInvalidReason,
|
||||
AuthServiceUnavailable, ClientAuthError, ClientSessionPersistFailed, PasswordLoginRejected,
|
||||
PhoneCodeLoginRejected, RuntimeSessionInstallFailed, SendCodeRejected, ServerAddressReason,
|
||||
ServerAddressRejected, UnexpectedRejection,
|
||||
};
|
||||
use crate::http_client::agc_main_site_client_builder;
|
||||
use crate::platform_session::{current_platform_session, PlatformSessionSnapshot};
|
||||
@@ -241,6 +242,19 @@ fn runtime_session_error(message: impl Into<String>) -> ClientAuthError {
|
||||
})
|
||||
}
|
||||
|
||||
/// 服务地址校验失败统一收口成带类型化原因的变体。
|
||||
fn server_address_rejected(reason: ServerAddressReason) -> ClientAuthError {
|
||||
ClientAuthError::ServerAddressRejected(ServerAddressRejected { reason })
|
||||
}
|
||||
|
||||
/// 响应契约破损(不含服务端原文)统一收口。
|
||||
fn response_invalid(reason: AuthResponseInvalidReason) -> ClientAuthError {
|
||||
ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
|
||||
reason,
|
||||
server_message: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 校验并归一化平台服务 origin。
|
||||
///
|
||||
/// 只接受纯 origin:不允许凭据、路径、查询或 fragment;非本机必须 HTTPS。发布构建额外
|
||||
@@ -250,28 +264,35 @@ fn runtime_session_error(message: impl Into<String>) -> ClientAuthError {
|
||||
pub(crate) fn validate_client_api_base_url(value: &str) -> Result<String, ClientAuthError> {
|
||||
let trimmed = value.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() || trimmed.chars().count() > MAX_ORIGIN_CHARS {
|
||||
return Err(ClientAuthError::ServerAddressEmptyOrTooLong);
|
||||
return Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong));
|
||||
}
|
||||
let parsed = Url::parse(trimmed).map_err(|_| ClientAuthError::ServerAddressNotAUrl)?;
|
||||
let parsed =
|
||||
Url::parse(trimmed).map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?;
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(ClientAuthError::ServerAddressHasCredentials);
|
||||
return Err(server_address_rejected(ServerAddressReason::HasCredentials));
|
||||
}
|
||||
if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some()
|
||||
{
|
||||
return Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment);
|
||||
return Err(server_address_rejected(
|
||||
ServerAddressReason::HasPathOrQueryOrFragment,
|
||||
));
|
||||
}
|
||||
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
|
||||
match parsed.scheme() {
|
||||
"https" => {}
|
||||
"http" if matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") => {}
|
||||
"http" => return Err(ClientAuthError::ServerAddressNotHttps),
|
||||
_ => return Err(ClientAuthError::ServerAddressUnsupportedScheme),
|
||||
"http" => return Err(server_address_rejected(ServerAddressReason::NotHttps)),
|
||||
_ => {
|
||||
return Err(server_address_rejected(
|
||||
ServerAddressReason::UnsupportedScheme,
|
||||
))
|
||||
}
|
||||
}
|
||||
if !cfg!(debug_assertions)
|
||||
&& !matches!(trimmed, RELEASE_ORIGIN | DEVELOPMENT_ORIGIN)
|
||||
&& !matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]")
|
||||
{
|
||||
return Err(ClientAuthError::ServerAddressOutsideChannel);
|
||||
return Err(server_address_rejected(ServerAddressReason::OutsideChannel));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
@@ -402,11 +423,11 @@ fn current_session_origin() -> Option<String> {
|
||||
|
||||
fn endpoint(origin: &str, route: &str) -> Result<String, ClientAuthError> {
|
||||
let mut url = Url::parse(&format!("{}/", origin.trim_end_matches('/')))
|
||||
.map_err(|_| ClientAuthError::ServerAddressNotAUrl)?;
|
||||
.map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| ClientAuthError::ServerAddressNotAUrl)?;
|
||||
.map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?;
|
||||
for segment in route.trim_start_matches('/').split('/') {
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
@@ -426,11 +447,12 @@ fn build_client() -> Result<reqwest::Client, ClientAuthError> {
|
||||
}
|
||||
|
||||
fn network_error(error: &reqwest::Error) -> ClientAuthError {
|
||||
if error.is_timeout() {
|
||||
ClientAuthError::AuthNetworkTimeout
|
||||
let reason = if error.is_timeout() {
|
||||
AuthNetworkReason::Timeout
|
||||
} else {
|
||||
ClientAuthError::AuthNetworkUnreachable
|
||||
}
|
||||
AuthNetworkReason::Unreachable
|
||||
};
|
||||
ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { reason })
|
||||
}
|
||||
|
||||
fn error_message(body: &str) -> Option<String> {
|
||||
@@ -536,14 +558,13 @@ fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientA
|
||||
}
|
||||
|
||||
fn response_data(body: &str) -> Result<Value, ClientAuthError> {
|
||||
let value: Value =
|
||||
serde_json::from_str(body).map_err(|_| ClientAuthError::AuthResponseNotJson)?;
|
||||
let value: Value = serde_json::from_str(body)
|
||||
.map_err(|_| response_invalid(AuthResponseInvalidReason::NotJson))?;
|
||||
if value.get("ok").and_then(Value::as_bool) == Some(false) {
|
||||
return Err(ClientAuthError::AuthResponseServerRejected(
|
||||
AuthResponseServerRejected {
|
||||
server_message: error_message(body),
|
||||
},
|
||||
));
|
||||
return Err(ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
|
||||
reason: AuthResponseInvalidReason::ServerRejected,
|
||||
server_message: error_message(body),
|
||||
}));
|
||||
}
|
||||
Ok(value.get("data").cloned().unwrap_or(value))
|
||||
}
|
||||
@@ -620,15 +641,18 @@ async fn request_auth(
|
||||
CookiePolicy::Ignore => None,
|
||||
};
|
||||
// 拿到状态码后读 body 失败属于传输层故障:按网络不可达上报,不猜契约。
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|_| ClientAuthError::AuthNetworkUnreachable)?;
|
||||
let text = response.text().await.map_err(|_| {
|
||||
ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
|
||||
reason: AuthNetworkReason::Unreachable,
|
||||
})
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(map_auth_failure(status, &text, auth_route(route)));
|
||||
}
|
||||
if matches!(policy, CookiePolicy::Require) && captured.is_none() {
|
||||
return Err(ClientAuthError::AuthResponseMissingRefreshCookie);
|
||||
return Err(response_invalid(
|
||||
AuthResponseInvalidReason::MissingRefreshCookie,
|
||||
));
|
||||
}
|
||||
Ok(AuthResponse {
|
||||
data: response_data(&text)?,
|
||||
@@ -667,7 +691,7 @@ async fn commit_authenticated_session(
|
||||
identity_change: bool,
|
||||
) -> Result<AuthUserPayload, ClientAuthError> {
|
||||
if token.chars().count() > MAX_SECRET_CHARS {
|
||||
return Err(ClientAuthError::AuthResponseInvalidBody);
|
||||
return Err(response_invalid(AuthResponseInvalidReason::InvalidBody));
|
||||
}
|
||||
// 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。
|
||||
let (user_id, known_user) = identity.resolve()?;
|
||||
@@ -758,7 +782,9 @@ fn phone_is_valid(phone: &str) -> bool {
|
||||
fn validated_session_user_id(user_id: &str) -> Result<String, ClientAuthError> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return Err(ClientAuthError::AuthResponseMissingUserIdentity);
|
||||
return Err(response_invalid(
|
||||
AuthResponseInvalidReason::MissingUserIdentity,
|
||||
));
|
||||
}
|
||||
Ok(user_id.to_string())
|
||||
}
|
||||
@@ -790,7 +816,7 @@ async fn fetch_current_user(
|
||||
)
|
||||
.await?;
|
||||
let me: MeResponse = serde_json::from_value(response.data)
|
||||
.map_err(|_| ClientAuthError::AuthResponseInvalidBody)?;
|
||||
.map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?;
|
||||
Ok(me.user)
|
||||
}
|
||||
|
||||
@@ -860,11 +886,11 @@ async fn refresh_session_inner(
|
||||
}
|
||||
};
|
||||
let token: TokenResponse = serde_json::from_value(refreshed.data)
|
||||
.map_err(|_| ClientAuthError::AuthResponseInvalidBody)?;
|
||||
.map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?;
|
||||
let install_token = token.token.clone();
|
||||
let new_cookie = refreshed
|
||||
.refresh_cookie
|
||||
.ok_or(ClientAuthError::AuthResponseMissingRefreshCookie)?;
|
||||
.ok_or_else(|| response_invalid(AuthResponseInvalidReason::MissingRefreshCookie))?;
|
||||
commit_authenticated_session(
|
||||
app,
|
||||
&session.api_base_url,
|
||||
@@ -1009,7 +1035,7 @@ pub(crate) async fn send_client_phone_login_code(
|
||||
)
|
||||
.await?;
|
||||
let payload: SendCodeResponse = serde_json::from_value(response.data)
|
||||
.map_err(|_| ClientAuthError::AuthResponseInvalidBody)?;
|
||||
.map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?;
|
||||
Ok(ClientLoginCodeView {
|
||||
cooldown_seconds: payload.cooldown_seconds,
|
||||
expires_in_seconds: payload.expires_in_seconds,
|
||||
@@ -1022,10 +1048,10 @@ async fn complete_login(
|
||||
response: AuthResponse,
|
||||
) -> Result<AuthUserPayload, ClientAuthError> {
|
||||
let payload: TokenUserResponse = serde_json::from_value(response.data)
|
||||
.map_err(|_| ClientAuthError::AuthResponseInvalidBody)?;
|
||||
.map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?;
|
||||
let cookie = response
|
||||
.refresh_cookie
|
||||
.ok_or(ClientAuthError::AuthResponseMissingRefreshCookie)?;
|
||||
.ok_or_else(|| response_invalid(AuthResponseInvalidReason::MissingRefreshCookie))?;
|
||||
let user = commit_authenticated_session(
|
||||
app,
|
||||
origin,
|
||||
@@ -1161,45 +1187,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn origins_reject_credentials_paths_and_plain_remote_http() {
|
||||
// 每种拒绝各成一个变体:前端只按变体选提示,不读任何文案。
|
||||
// 顶层只占一个变体,细分走类型化 reason:前端 switch(reason) 仍然按类型分流,不读文案。
|
||||
assert_eq!(
|
||||
validate_client_api_base_url("https://user:pass@example.com"),
|
||||
Err(ClientAuthError::ServerAddressHasCredentials)
|
||||
Err(server_address_rejected(ServerAddressReason::HasCredentials))
|
||||
);
|
||||
assert_eq!(
|
||||
validate_client_api_base_url("https://example.com/api"),
|
||||
Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment)
|
||||
Err(server_address_rejected(
|
||||
ServerAddressReason::HasPathOrQueryOrFragment
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
validate_client_api_base_url("https://example.com?a=1"),
|
||||
Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment)
|
||||
Err(server_address_rejected(
|
||||
ServerAddressReason::HasPathOrQueryOrFragment
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
validate_client_api_base_url("https://example.com#frag"),
|
||||
Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment)
|
||||
Err(server_address_rejected(
|
||||
ServerAddressReason::HasPathOrQueryOrFragment
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
validate_client_api_base_url("http://example.com"),
|
||||
Err(ClientAuthError::ServerAddressNotHttps)
|
||||
Err(server_address_rejected(ServerAddressReason::NotHttps))
|
||||
);
|
||||
assert_eq!(
|
||||
validate_client_api_base_url("ftp://example.com"),
|
||||
Err(ClientAuthError::ServerAddressUnsupportedScheme)
|
||||
Err(server_address_rejected(
|
||||
ServerAddressReason::UnsupportedScheme
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
validate_client_api_base_url(" "),
|
||||
Err(ClientAuthError::ServerAddressEmptyOrTooLong)
|
||||
Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong))
|
||||
);
|
||||
assert_eq!(
|
||||
validate_client_api_base_url("不是地址"),
|
||||
Err(ClientAuthError::ServerAddressNotAUrl)
|
||||
Err(server_address_rejected(ServerAddressReason::NotAUrl))
|
||||
);
|
||||
assert_eq!(
|
||||
validate_client_api_base_url(&format!(
|
||||
"https://example.com/{}",
|
||||
"a".repeat(MAX_ORIGIN_CHARS)
|
||||
)),
|
||||
Err(ClientAuthError::ServerAddressEmptyOrTooLong)
|
||||
Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong))
|
||||
);
|
||||
assert_eq!(
|
||||
validate_client_api_base_url("http://127.0.0.1:10001").expect("loopback debug"),
|
||||
@@ -1521,7 +1555,7 @@ mod tests {
|
||||
// 是内部不变式,不是用户能理解的登录失败原因。
|
||||
assert_eq!(
|
||||
validated_session_user_id(" ").unwrap_err(),
|
||||
ClientAuthError::AuthResponseMissingUserIdentity
|
||||
response_invalid(AuthResponseInvalidReason::MissingUserIdentity)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1580,7 +1614,7 @@ mod tests {
|
||||
// 没有主体的登录响应在写凭据文件之前就以登录语义失败关闭。
|
||||
assert_eq!(
|
||||
SessionIdentity::Login(payload.user).resolve().unwrap_err(),
|
||||
ClientAuthError::AuthResponseMissingUserIdentity
|
||||
response_invalid(AuthResponseInvalidReason::MissingUserIdentity)
|
||||
);
|
||||
// 续期路径的空主体同样失败关闭:缺字段的凭据文件本来就读不出来,不能在这里被复活。
|
||||
assert!(SessionIdentity::Persisted(String::new()).resolve().is_err());
|
||||
|
||||
Reference in New Issue
Block a user