Unlucky Launcher v0.2.2
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
This commit is contained in:
commit
1105a86448
1128 files changed
+217164
No files matched your search
@@ -0,0 +1,339 @@
|
||||
//! 账户模型:**条目 id 与 profileID 解耦**的三轨统一抽象。
|
||||
//!
|
||||
//! 承 `analyse/04` §2.3 / §3.2 / §12.1(1) 的三条硬教训:
|
||||
//! 1. 条目 id(`account:<32hex>`)**不得**等于 profileID / 登录名 / 服务器 URL——
|
||||
//! 这些值可能被多个条目共享(同一角色导入两次、服务器换域名);
|
||||
//! 2. **元数据 / 凭据双写分离**:`AccountMeta` 只装「能公开」的字段(列表渲染不需要解密);
|
||||
//! 3. 反序列化**允许不完整对象**(`profile_name` 可空 = 「有档案但名字待刷新」),
|
||||
//! 补齐推迟到登录时——不能因为名字未知就整条丢弃。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::authlib::AuthlibSession;
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::microsoft::MicrosoftSession;
|
||||
use crate::auth::secret::random_hex32;
|
||||
|
||||
/// 条目 id 前缀(防被误当 UUID;承 HMCL `AccountID.PREFIX`)。
|
||||
pub const ACCOUNT_ID_PREFIX: &str = "account:";
|
||||
|
||||
/// 账户条目 id:`account:<32 位十六进制>`。
|
||||
///
|
||||
/// 与 profileID 无关:改服务器地址、改玩家名、同角色多条目都不影响它。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct AccountId(String);
|
||||
|
||||
impl AccountId {
|
||||
/// 生成一个新条目 id。
|
||||
#[must_use]
|
||||
pub fn generate() -> Self {
|
||||
Self(format!("{ACCOUNT_ID_PREFIX}{}", random_hex32()))
|
||||
}
|
||||
|
||||
/// 从磁盘字符串解析(宽容:坏数据返回 `None`,不 panic)。
|
||||
#[must_use]
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
let body = raw.strip_prefix(ACCOUNT_ID_PREFIX)?;
|
||||
if body.len() != 32 || !body.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
Some(Self(raw.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// 字符串视图。
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AccountId {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 三条轨道。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AccountKind {
|
||||
/// 离线(本地生成的 UUID,不联网)。
|
||||
Offline,
|
||||
/// 微软(device-code / 授权码浏览器)。
|
||||
Microsoft,
|
||||
/// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。
|
||||
AuthlibInjector,
|
||||
}
|
||||
|
||||
impl AccountKind {
|
||||
/// 落盘用的稳定名(承 HMCL 的 `offline` / `microsoft` / `authlibInjector`)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Offline => "offline",
|
||||
Self::Microsoft => "microsoft",
|
||||
Self::AuthlibInjector => "authlibInjector",
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析(未知值返回 `None`,让上层跳过该条记录)。
|
||||
#[must_use]
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
match raw {
|
||||
"offline" => Some(Self::Offline),
|
||||
"microsoft" => Some(Self::Microsoft),
|
||||
// 兼容两种写法:HMCL 用 camelCase,别的地方有人写 kebab。
|
||||
"authlibInjector" | "authlib-injector" => Some(Self::AuthlibInjector),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读标签(CLI/JSON 展示用)。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Offline => "离线",
|
||||
Self::Microsoft => "微软",
|
||||
Self::AuthlibInjector => "authlib-injector",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 账户元数据(**可公开**,不含任何凭据)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountMeta {
|
||||
/// 条目 id。
|
||||
pub id: AccountId,
|
||||
/// 轨道。
|
||||
pub kind: AccountKind,
|
||||
/// Minecraft 档案 UUID(32 位无连字符)。**不可变**。
|
||||
pub profile_id: String,
|
||||
/// 展示名/玩家名;空串 = 「有档案但名字待刷新」(合法中间态)。
|
||||
#[serde(default)]
|
||||
pub profile_name: String,
|
||||
/// 登录名(authlib 轨的邮箱/用户名;其余轨为 `None`)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub login_name: Option<String>,
|
||||
/// authlib 认证服务器 API 根(其余轨为 `None`;凭据之外唯一需要持久化的服务器信息)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub server_url: Option<String>,
|
||||
/// 创建时刻(Unix 毫秒;仅用于排序/展示)。
|
||||
pub created_at_ms: u64,
|
||||
}
|
||||
|
||||
impl AccountMeta {
|
||||
/// 构造一条元数据(`profile_name` 可后补)。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
kind: AccountKind,
|
||||
profile_id: impl Into<String>,
|
||||
profile_name: impl Into<String>,
|
||||
created_at_ms: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: AccountId::generate(),
|
||||
kind,
|
||||
profile_id: profile_id.into(),
|
||||
profile_name: profile_name.into(),
|
||||
login_name: None,
|
||||
server_url: None,
|
||||
created_at_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示名:优先玩家名,其次登录名,最后条目 id(永不 panic、永不空串)。
|
||||
#[must_use]
|
||||
pub fn display_name(&self) -> &str {
|
||||
if !self.profile_name.trim().is_empty() {
|
||||
return &self.profile_name;
|
||||
}
|
||||
if let Some(login_name) = &self.login_name {
|
||||
if !login_name.trim().is_empty() {
|
||||
return login_name;
|
||||
}
|
||||
}
|
||||
self.id.as_str()
|
||||
}
|
||||
|
||||
/// 该条目是否匹配一个查找键(名字 / 登录名 / 条目 id)。
|
||||
///
|
||||
/// 大小写不敏感:玩家名在 MC 里大小写不敏感,CLI 用户不该为此折腾。
|
||||
#[must_use]
|
||||
pub fn matches(&self, key: &str) -> bool {
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.profile_name.eq_ignore_ascii_case(key)
|
||||
|| self
|
||||
.login_name
|
||||
.as_deref()
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case(key))
|
||||
|| self.id.as_str().eq_ignore_ascii_case(key)
|
||||
}
|
||||
|
||||
/// 该条目的凭据是否与轨道匹配(防止磁盘上元数据被换成另一轨)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 轨道与凭据种类不一致时返回 [`AuthError::Malformed`]。
|
||||
pub fn check_credentials(&self, credentials: &Credentials) -> AuthResult<()> {
|
||||
if credentials.kind() == self.kind {
|
||||
return Ok(());
|
||||
}
|
||||
Err(AuthError::Malformed(format!(
|
||||
"账户 {} 的元数据轨道是 {},凭据却是 {}",
|
||||
self.id,
|
||||
self.kind.as_str(),
|
||||
credentials.kind().as_str()
|
||||
)))
|
||||
}
|
||||
|
||||
/// 从 JSON 值宽容解析(单条坏记录只返回 `Err`,由调用方跳过并记 warning)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 缺字段/类型不符/轨道未知时返回 [`AuthError::Malformed`]。
|
||||
pub fn from_value(value: &serde_json::Value) -> AuthResult<Self> {
|
||||
serde_json::from_value(value.clone())
|
||||
.map_err(|error| AuthError::Malformed(format!("账户元数据不合法:{error}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// 凭据载荷(**私有**,只落 `credentials.dat`,永不进列表/日志)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Credentials {
|
||||
/// 离线账户没有凭据(accessToken 每次登录随机生成,不落盘)。
|
||||
Offline,
|
||||
/// 微软会话(access + refresh + `notAfter`)。
|
||||
Microsoft(MicrosoftSession),
|
||||
/// authlib 会话(accessToken + clientToken)。
|
||||
AuthlibInjector(AuthlibSession),
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
/// 所属轨道。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> AccountKind {
|
||||
match self {
|
||||
Self::Offline => AccountKind::Offline,
|
||||
Self::Microsoft(_) => AccountKind::Microsoft,
|
||||
Self::AuthlibInjector(_) => AccountKind::AuthlibInjector,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该凭据里的所有明文敏感值(武装脱敏器用;离线轨为空)。
|
||||
#[must_use]
|
||||
pub fn secrets(&self) -> Vec<&str> {
|
||||
match self {
|
||||
Self::Offline => Vec::new(),
|
||||
Self::Microsoft(session) => session
|
||||
.secrets()
|
||||
.into_iter()
|
||||
.map(|secret| secret.expose())
|
||||
.collect(),
|
||||
Self::AuthlibInjector(session) => session
|
||||
.secrets()
|
||||
.into_iter()
|
||||
.map(|secret| secret.expose())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AccountId, AccountKind, AccountMeta, Credentials};
|
||||
use crate::auth::microsoft::MicrosoftSession;
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
#[test]
|
||||
fn account_id_shape_and_parse() {
|
||||
let id = AccountId::generate();
|
||||
assert!(id.as_str().starts_with("account:"));
|
||||
assert_eq!(id.as_str().len(), "account:".len() + 32);
|
||||
assert_eq!(AccountId::parse(id.as_str()), Some(id.clone()));
|
||||
assert_eq!(AccountId::parse("account:xyz"), None);
|
||||
assert_eq!(AccountId::parse("0192-blah"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_roundtrip() {
|
||||
for kind in [
|
||||
AccountKind::Offline,
|
||||
AccountKind::Microsoft,
|
||||
AccountKind::AuthlibInjector,
|
||||
] {
|
||||
assert_eq!(AccountKind::parse(kind.as_str()), Some(kind));
|
||||
assert!(!kind.label().is_empty());
|
||||
}
|
||||
assert_eq!(AccountKind::parse("nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_matches_name_login_and_id() {
|
||||
let mut meta = AccountMeta::new(AccountKind::Offline, "uuid32", "Steve", 1);
|
||||
meta.login_name = Some("steve@example.com".to_string());
|
||||
assert!(meta.matches("Steve"));
|
||||
assert!(meta.matches("steve"));
|
||||
assert!(meta.matches("steve@example.com"));
|
||||
assert!(meta.matches(meta.id.as_str()));
|
||||
assert!(!meta.matches("Alex"));
|
||||
assert!(!meta.matches(" "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_falls_back() {
|
||||
let mut meta = AccountMeta::new(AccountKind::Microsoft, "uuid", "", 1);
|
||||
assert_eq!(meta.display_name(), meta.id.as_str());
|
||||
meta.login_name = Some("me@example.com".to_string());
|
||||
assert_eq!(meta.display_name(), "me@example.com");
|
||||
meta.profile_name = "Steve".to_string();
|
||||
assert_eq!(meta.display_name(), "Steve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_kind_must_match_meta() {
|
||||
let meta = AccountMeta::new(AccountKind::Microsoft, "uuid", "Steve", 1);
|
||||
let session = MicrosoftSession::new(
|
||||
"uuid",
|
||||
"Steve",
|
||||
Secret::new("a"),
|
||||
Secret::new("r"),
|
||||
60,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
assert!(meta
|
||||
.check_credentials(&Credentials::Microsoft(session))
|
||||
.is_ok());
|
||||
assert!(meta.check_credentials(&Credentials::Offline).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_only_expose_real_credentials() {
|
||||
assert!(Credentials::Offline.secrets().is_empty());
|
||||
let session = MicrosoftSession::new(
|
||||
"uuid",
|
||||
"Steve",
|
||||
Secret::new("mc-token"),
|
||||
Secret::new("live-refresh"),
|
||||
60,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
Credentials::Microsoft(session).secrets(),
|
||||
vec!["mc-token", "live-refresh"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_record_is_an_error_not_a_panic() {
|
||||
let value = serde_json::json!({ "id": "account:zz", "kind": "offline" });
|
||||
assert!(AccountMeta::from_value(&value).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! authlib-injector 轨(**账户侧**):标准 Yggdrasil 协议的登录/刷新/校验/注销。
|
||||
//!
|
||||
//! 边界(mission 明示):本模块只做「账户」;`-javaagent` 三件套(jar 下载、
|
||||
//! `-Dauthlibinjector.side=client`、`yggdrasil.prefetched` 的 Base64)归后续 mission。
|
||||
//! 依据 `analyse/04` §6.1–§6.2(端点布局与四动作报文)与 §6.3(服务发现约定)。
|
||||
//!
|
||||
//! 三条照抄要点:
|
||||
//! - 端点 = `<apiRoot>authserver/{authenticate,refresh,validate,invalidate}`,`apiRoot`
|
||||
//! 必须带尾斜杠(`https://littleskin.cn/api/yggdrasil/` 这种目录布局是规范要求);
|
||||
//! - `clientToken` 是**启动器侧生成的随机值**,服务端回传必须一致(防会话被替换/重放);
|
||||
//! - 「token 无效」用**返回值 `false`** 表达(`validate` 的 `ForbiddenOperationException`),
|
||||
//! 「服务器抽风」才抛错——调用方据此决定「刷新」还是「重试」。
|
||||
|
||||
pub mod server_meta;
|
||||
|
||||
pub use server_meta::AuthlibServerMeta;
|
||||
|
||||
use reqwest::Client;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::http::{post_json, Reply};
|
||||
use crate::auth::secret::Secret;
|
||||
use crate::download::config::EngineConfig;
|
||||
use crate::download::http::build_client;
|
||||
use crate::launch::request::AuthPlaceholders;
|
||||
|
||||
/// authlib 轨的 launch 用户类型(承 HMCL `YggdrasilSession.toAuthInfo`:也是 `msa`)。
|
||||
pub const USER_TYPE_MSA: &str = "msa";
|
||||
|
||||
/// 把用户输入规范成 API 根:补 `https://`、补尾斜杠、去空白。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 空串或只剩协议头时返回 [`AuthError::Malformed`]。
|
||||
pub fn normalize_api_root(input: &str) -> AuthResult<String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AuthError::Malformed(
|
||||
"认证服务器地址不能为空(例:https://littleskin.cn/api/yggdrasil/)".to_string(),
|
||||
));
|
||||
}
|
||||
let with_scheme = if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("https://{trimmed}")
|
||||
};
|
||||
let host = with_scheme
|
||||
.split_once("://")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or("");
|
||||
if host.trim_matches('/').is_empty() {
|
||||
return Err(AuthError::Malformed(format!(
|
||||
"认证服务器地址不完整:`{input}`"
|
||||
)));
|
||||
}
|
||||
Ok(if with_scheme.ends_with('/') {
|
||||
with_scheme
|
||||
} else {
|
||||
format!("{with_scheme}/")
|
||||
})
|
||||
}
|
||||
|
||||
/// Yggdrasil 四个端点(都从 API 根派生)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthlibEndpoints {
|
||||
/// `authserver/authenticate`。
|
||||
pub authenticate: String,
|
||||
/// `authserver/refresh`。
|
||||
pub refresh: String,
|
||||
/// `authserver/validate`。
|
||||
pub validate: String,
|
||||
/// `authserver/invalidate`。
|
||||
pub invalidate: String,
|
||||
}
|
||||
|
||||
impl AuthlibEndpoints {
|
||||
/// 从 API 根派生(先 [`normalize_api_root`])。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 地址不合法时返回 [`AuthError::Malformed`]。
|
||||
pub fn from_api_root(api_root: &str) -> AuthResult<Self> {
|
||||
let root = normalize_api_root(api_root)?;
|
||||
let join = |path: &str| format!("{root}{path}");
|
||||
Ok(Self {
|
||||
authenticate: join("authserver/authenticate"),
|
||||
refresh: join("authserver/refresh"),
|
||||
validate: join("authserver/validate"),
|
||||
invalidate: join("authserver/invalidate"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 Yggdrasil 角色(`selectedProfile`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AuthlibProfile {
|
||||
/// 32 位无连字符 UUID。
|
||||
pub id: String,
|
||||
/// 角色名。
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// authlib 会话 = 该账户的凭据载荷。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthlibSession {
|
||||
/// Yggdrasil accessToken。
|
||||
pub access_token: Secret,
|
||||
/// 启动器生成的 clientToken(刷新/校验都要带)。
|
||||
pub client_token: Secret,
|
||||
/// 当前选中的角色(多角色服务器里由 refresh 的 `selectedProfile` 决定)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selected_profile: Option<AuthlibProfile>,
|
||||
}
|
||||
|
||||
impl AuthlibSession {
|
||||
/// 该会话的全部凭据值(武装脱敏器)。
|
||||
#[must_use]
|
||||
pub fn secrets(&self) -> Vec<&Secret> {
|
||||
vec![&self.access_token, &self.client_token]
|
||||
}
|
||||
|
||||
/// 有没有角色(没角色的会话不能启动游戏)。
|
||||
#[must_use]
|
||||
pub fn has_profile(&self) -> bool {
|
||||
self.selected_profile
|
||||
.as_ref()
|
||||
.is_some_and(|profile| !profile.name.trim().is_empty() && !profile.id.is_empty())
|
||||
}
|
||||
|
||||
/// 转成 launch 占位符。
|
||||
///
|
||||
/// `user_properties` 用 `{}`:纹理由游戏进程经 authlib-injector 向服务器取
|
||||
/// (注入参数属后续 mission),启动参数不需要它。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 会话没有角色时返回 [`AuthError::CredentialExpired`](需要重新选角色/登录)。
|
||||
pub fn to_placeholders(&self) -> AuthResult<AuthPlaceholders> {
|
||||
let profile = self
|
||||
.selected_profile
|
||||
.as_ref()
|
||||
.filter(|profile| !profile.name.trim().is_empty())
|
||||
.ok_or(AuthError::CredentialExpired)?;
|
||||
Ok(AuthPlaceholders {
|
||||
player_name: profile.name.clone(),
|
||||
uuid: profile.id.clone(),
|
||||
access_token: self.access_token.expose().to_string(),
|
||||
user_type: USER_TYPE_MSA.to_string(),
|
||||
user_properties: "{}".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// authlib 认证客户端。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthlibAuth {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl AuthlibAuth {
|
||||
/// 构造(HTTP 基座复用 `core::download`)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 客户端建不起来时返回 [`AuthError::Network`]。
|
||||
pub fn new() -> AuthResult<Self> {
|
||||
let client = build_client(&EngineConfig::default())
|
||||
.map_err(|error| AuthError::network("http-client", error.to_string()))?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
/// 注入客户端(测试)。
|
||||
#[must_use]
|
||||
pub fn with_client(mut self, client: Client) -> Self {
|
||||
self.client = client;
|
||||
self
|
||||
}
|
||||
|
||||
/// 密码登录(**新账户**)。密码只在本调用栈里出现,不落盘、不入日志。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 密码错/服务器拒绝 → [`AuthError::Denied`];网络与结构问题各有分流。
|
||||
pub async fn authenticate(
|
||||
&self,
|
||||
api_root: &str,
|
||||
login_name: &str,
|
||||
password: &Secret,
|
||||
client_token: &Secret,
|
||||
) -> AuthResult<AuthlibSession> {
|
||||
let endpoints = AuthlibEndpoints::from_api_root(api_root)?;
|
||||
let body = json!({
|
||||
"agent": { "name": "Minecraft", "version": 1 },
|
||||
"username": login_name,
|
||||
"password": password.expose(),
|
||||
"clientToken": client_token.expose(),
|
||||
"requestUser": true,
|
||||
});
|
||||
let reply = post_json(&self.client, &endpoints.authenticate, &body, None).await?;
|
||||
if !reply.is_success() {
|
||||
return Err(rejected(&reply, &endpoints.authenticate, "登录"));
|
||||
}
|
||||
let session = session_from(&reply, client_token, &endpoints.authenticate)?;
|
||||
if !session.has_profile() {
|
||||
// 服务端没自动选角色(多角色服务器):交给调用方走 refresh + selectedProfile。
|
||||
return Ok(session);
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// 刷新(可顺带 `selectedProfile` 换角色)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// token 失效 → [`AuthError::CredentialExpired`];换角色失败 → [`AuthError::Malformed`]。
|
||||
pub async fn refresh(
|
||||
&self,
|
||||
api_root: &str,
|
||||
session: &AuthlibSession,
|
||||
select: Option<&AuthlibProfile>,
|
||||
) -> AuthResult<AuthlibSession> {
|
||||
let endpoints = AuthlibEndpoints::from_api_root(api_root)?;
|
||||
let mut body = json!({
|
||||
"accessToken": session.access_token.expose(),
|
||||
"clientToken": session.client_token.expose(),
|
||||
"requestUser": true,
|
||||
});
|
||||
if let Some(profile) = select {
|
||||
body["selectedProfile"] = json!({ "id": profile.id, "name": profile.name });
|
||||
}
|
||||
let reply = post_json(&self.client, &endpoints.refresh, &body, None).await?;
|
||||
if !reply.is_success() {
|
||||
if is_forbidden(&reply) {
|
||||
return Err(AuthError::CredentialExpired);
|
||||
}
|
||||
return Err(rejected(&reply, &endpoints.refresh, "刷新"));
|
||||
}
|
||||
let refreshed = session_from(&reply, &session.client_token, &endpoints.refresh)?;
|
||||
if let Some(wanted) = select {
|
||||
let got = refreshed.selected_profile.as_ref();
|
||||
if got.map(|profile| profile.id.as_str()) != Some(wanted.id.as_str()) {
|
||||
return Err(AuthError::Malformed(format!(
|
||||
"服务器没有切到角色 {}(refresh 未生效)",
|
||||
wanted.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(refreshed)
|
||||
}
|
||||
|
||||
/// 校验 token 是否仍有效(`ForbiddenOperationException` = `Ok(false)`)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 服务器 5xx/网络失败时返回错误(调用方据此区分「要刷新」与「要重试」)。
|
||||
pub async fn validate(&self, api_root: &str, session: &AuthlibSession) -> AuthResult<bool> {
|
||||
let endpoints = AuthlibEndpoints::from_api_root(api_root)?;
|
||||
let body = json!({
|
||||
"accessToken": session.access_token.expose(),
|
||||
"clientToken": session.client_token.expose(),
|
||||
});
|
||||
let reply = post_json(&self.client, &endpoints.validate, &body, None).await?;
|
||||
if is_forbidden(&reply) {
|
||||
return Ok(false);
|
||||
}
|
||||
if !reply.is_success() {
|
||||
return Err(rejected(&reply, &endpoints.validate, "校验"));
|
||||
}
|
||||
// 成功时 body 为空或 `{}`,都算有效。
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 主动注销(删账户时顺手清理服务端会话;失败按「尽力而为」返回错误,调用方可不理)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 网络/服务端失败时返回错误。
|
||||
pub async fn invalidate(&self, api_root: &str, session: &AuthlibSession) -> AuthResult<()> {
|
||||
let endpoints = AuthlibEndpoints::from_api_root(api_root)?;
|
||||
let body = json!({
|
||||
"accessToken": session.access_token.expose(),
|
||||
"clientToken": session.client_token.expose(),
|
||||
});
|
||||
let reply = post_json(&self.client, &endpoints.invalidate, &body, None).await?;
|
||||
if reply.is_success() || is_forbidden(&reply) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(rejected(&reply, &endpoints.invalidate, "注销"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 响应 → 会话(`clientToken` 回传必须一致;缺失时保留原值)。
|
||||
fn session_from(reply: &Reply, client_token: &Secret, url: &str) -> AuthResult<AuthlibSession> {
|
||||
let access_token = reply
|
||||
.text("accessToken")
|
||||
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 accessToken")))?;
|
||||
if let Some(returned) = reply.text("clientToken") {
|
||||
if returned != client_token.expose() {
|
||||
return Err(AuthError::Malformed(
|
||||
"服务器回传的 clientToken 与本地不一致(会话可能被替换)".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(AuthlibSession {
|
||||
access_token: Secret::new(access_token),
|
||||
client_token: client_token.clone(),
|
||||
selected_profile: selected_profile(reply),
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_profile(reply: &Reply) -> Option<AuthlibProfile> {
|
||||
let profile = reply
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("selectedProfile"))?;
|
||||
let id = profile.get("id")?.as_str()?;
|
||||
let name = profile.get("name")?.as_str()?;
|
||||
Some(AuthlibProfile {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_forbidden(reply: &Reply) -> bool {
|
||||
reply.text("error") == Some("ForbiddenOperationException")
|
||||
}
|
||||
|
||||
fn rejected(reply: &Reply, url: &str, what: &str) -> AuthError {
|
||||
match reply.text("error") {
|
||||
Some(code) => AuthError::Denied {
|
||||
code: code.to_string(),
|
||||
description: reply
|
||||
.text("errorMessage")
|
||||
.unwrap_or("服务器未给描述")
|
||||
.to_string(),
|
||||
},
|
||||
None => AuthError::network(
|
||||
url.split('?').next().unwrap_or(url),
|
||||
format!("{what}失败(HTTP {})", reply.status),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,86 @@
|
||||
//! 认证服务器元数据里我们真正需要的字段(服务发现的产物)。
|
||||
//!
|
||||
//! authlib-injector 规范里根路径 `/` 返回 `{ meta, skinDomains, signaturePublickey }`
|
||||
//! (`analyse/04` §6.3)。本期**只保存**名字/非邮箱登录/skinDomains:
|
||||
//! `signaturePublickey` 与 `prefetched` 的 Base64 属于注入参数(后续 mission),
|
||||
//! 现在存了也没人消费,不如不存。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// 服务器元数据(缺失字段一律回落默认值)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthlibServerMeta {
|
||||
/// 服务器显示名(缺省回落 URL)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// 是否允许非邮箱登录名(决定输入框校验)。
|
||||
#[serde(default)]
|
||||
pub non_email_login: bool,
|
||||
/// `skinDomains`(纹理白名单,注入参数会用;本期只保存)。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skin_domains: Vec<String>,
|
||||
}
|
||||
|
||||
impl AuthlibServerMeta {
|
||||
/// 从根元数据 JSON 解析(容错:缺字段就用默认值)。
|
||||
#[must_use]
|
||||
pub fn from_value(value: &Value) -> Self {
|
||||
let meta = value.get("meta");
|
||||
let name = meta
|
||||
.and_then(|meta| meta.get("serverName"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let non_email_login = meta
|
||||
.and_then(|meta| meta.get("feature.non_email_login"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let skin_domains = value
|
||||
.get("skinDomains")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
name,
|
||||
non_email_login,
|
||||
skin_domains,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AuthlibServerMeta;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parses_full_metadata() {
|
||||
let value = json!({
|
||||
"meta": {
|
||||
"serverName": "Example Skin Site",
|
||||
"feature.non_email_login": true
|
||||
},
|
||||
"skinDomains": ["example.com", "*.example.com"],
|
||||
"signaturePublickey": "-----BEGIN PUBLIC KEY-----"
|
||||
});
|
||||
let meta = AuthlibServerMeta::from_value(&value);
|
||||
assert_eq!(meta.name.as_deref(), Some("Example Skin Site"));
|
||||
assert!(meta.non_email_login);
|
||||
assert_eq!(meta.skin_domains.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_missing_fields() {
|
||||
let meta = AuthlibServerMeta::from_value(&json!({}));
|
||||
assert_eq!(meta, AuthlibServerMeta::default());
|
||||
assert!(meta.name.is_none());
|
||||
assert!(!meta.non_email_login);
|
||||
assert!(meta.skin_domains.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! authlib 轨的纯函数/模型测试(HTTP 部分在 `tests/auth/authlib_api.rs`)。
|
||||
|
||||
use super::{normalize_api_root, AuthlibEndpoints, AuthlibProfile, AuthlibSession, USER_TYPE_MSA};
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
#[test]
|
||||
fn api_root_normalization() {
|
||||
assert_eq!(
|
||||
normalize_api_root("littleskin.cn/api/yggdrasil").expect("补协议"),
|
||||
"https://littleskin.cn/api/yggdrasil/"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_root(" http://127.0.0.1:8080/api/yggdrasil/ ").expect("保留协议"),
|
||||
"http://127.0.0.1:8080/api/yggdrasil/"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_root("https://littleskin.cn").expect("根域名"),
|
||||
"https://littleskin.cn/"
|
||||
);
|
||||
assert!(normalize_api_root(" ").is_err());
|
||||
assert!(normalize_api_root("https://").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoints_follow_directory_layout() {
|
||||
let endpoints =
|
||||
AuthlibEndpoints::from_api_root("https://example.invalid/api/yggdrasil").expect("端点");
|
||||
assert_eq!(
|
||||
endpoints.authenticate,
|
||||
"https://example.invalid/api/yggdrasil/authserver/authenticate"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoints.refresh,
|
||||
"https://example.invalid/api/yggdrasil/authserver/refresh"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoints.validate,
|
||||
"https://example.invalid/api/yggdrasil/authserver/validate"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoints.invalidate,
|
||||
"https://example.invalid/api/yggdrasil/authserver/invalidate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_require_a_profile() {
|
||||
let without = AuthlibSession {
|
||||
access_token: Secret::new("token"),
|
||||
client_token: Secret::new("client"),
|
||||
selected_profile: None,
|
||||
};
|
||||
assert!(!without.has_profile());
|
||||
assert!(without.to_placeholders().is_err());
|
||||
|
||||
let with = AuthlibSession {
|
||||
selected_profile: Some(AuthlibProfile {
|
||||
id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
name: "Steve".to_string(),
|
||||
}),
|
||||
..without
|
||||
};
|
||||
assert!(with.has_profile());
|
||||
let placeholders = with.to_placeholders().expect("占位符");
|
||||
assert_eq!(placeholders.player_name, "Steve");
|
||||
assert_eq!(placeholders.uuid, "0123456789abcdef0123456789abcdef");
|
||||
assert_eq!(placeholders.user_type, USER_TYPE_MSA);
|
||||
assert_eq!(placeholders.access_token, "token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_debug_never_leaks_tokens() {
|
||||
let session = AuthlibSession {
|
||||
access_token: Secret::new("access-token-value"),
|
||||
client_token: Secret::new("client-token-value"),
|
||||
selected_profile: None,
|
||||
};
|
||||
let debug = format!("{session:?}");
|
||||
assert!(!debug.contains("access-token-value"));
|
||||
assert!(!debug.contains("client-token-value"));
|
||||
assert_eq!(session.secrets().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_survives_json_roundtrip() {
|
||||
let session = AuthlibSession {
|
||||
access_token: Secret::new("access-token-value"),
|
||||
client_token: Secret::new("client-token-value"),
|
||||
selected_profile: Some(AuthlibProfile {
|
||||
id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
name: "Steve".to_string(),
|
||||
}),
|
||||
};
|
||||
let text = serde_json::to_string(&session).expect("序列化");
|
||||
let back: AuthlibSession = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, session);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! 非 2xx 应答的**正文诊断**(M38):把 M31「响应体一律丢掉」这个盲区补上。
|
||||
//!
|
||||
//! 起因是 `findings/20261030-tower-improve-login-with-xbox-403.md`:MSA 真机验收两轮都是
|
||||
//! OAuth→XBL→XSTS 全通、`login_with_xbox` 稳定 403,而旧 `rejected()` 只报状态码,
|
||||
//! 于是「区域/网络拦截页」和「服务端拒绝(许可/区域)」在文案上分不开。
|
||||
//!
|
||||
//! 这里只做三件事:
|
||||
//! 1. **分类**:JSON(真实服务应答)/ HTML 或纯文本(多为网关、风控或代理拦截页)/ 空体;
|
||||
//! 2. **提取**:JSON 里的 `error` / `errorMessage` / `reason` / `Message` 等服务端文案直出;
|
||||
//! 3. **摘要**:正文压成 ≤[`DIGEST_CHARS`] 字符,**先过 [`Redactor`] 再进错误消息**。
|
||||
//!
|
||||
//! 凭据红线(M31 的 `Secret`/脱敏体系不破):
|
||||
//! - 摘要只从**非 2xx** 响应构造——OAuth 成功响应体(含 token)永远不会走到这里;
|
||||
//! - `Reply` 的原文快照只在 JSON 解析失败时保留(见 [`crate::auth::http`]),
|
||||
//! 解析成功的应答不留原文,避免把 token 文本复制进第二个字段;
|
||||
//! - 摘要与提取出的服务端文案都过同一套脱敏:[`Redactor::scrub`](已登记 token 整串替换、
|
||||
//! ≥64 不透明串打码、JWT 形状的带点 token 打码——第三条 M40 已上提到 `launch::redact`,
|
||||
//! 本模块不再自己补规则)。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::auth::http::Reply;
|
||||
use crate::launch::redact::Redactor;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// 错误文案 / 事件里正文摘要的上限(字符)。
|
||||
pub const DIGEST_CHARS: usize = 200;
|
||||
|
||||
/// 服务端文案的取值字段(按优先级,命中即收;`Message` 是 XSTS 的写法)。
|
||||
const SERVER_MESSAGE_KEYS: [&str; 6] = [
|
||||
"errorMessage",
|
||||
"error_message",
|
||||
"error",
|
||||
"errorType",
|
||||
"reason",
|
||||
"Message",
|
||||
];
|
||||
|
||||
/// 一个非 2xx 应答的正文形状。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BodyKind {
|
||||
/// 合法 JSON(真实服务应答)。
|
||||
Json,
|
||||
/// HTML 页面(网关/风控/代理拦截页的典型形状)。
|
||||
Html,
|
||||
/// 其它非 JSON 文本。
|
||||
Text,
|
||||
/// 空体。
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl BodyKind {
|
||||
/// 稳定名(错误文案与 JSONL 事件共用)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Json => "json",
|
||||
Self::Html => "html",
|
||||
Self::Text => "text",
|
||||
Self::Empty => "empty",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否是「拦截页」形状(HTML / 纯文本):这一类先怀疑网络,而不是账号。
|
||||
#[must_use]
|
||||
pub const fn is_interception(self) -> bool {
|
||||
matches!(self, Self::Html | Self::Text)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次非 2xx 的诊断结果([`AuthError::Response`] 的载荷,也是 JSONL 事件的字段来源)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResponseDetail {
|
||||
/// 链上的步骤(`xbl` / `xsts` / `login_with_xbox` / `entitlements` / `profile`)。
|
||||
pub step: String,
|
||||
/// 这一步在做什么(人话前缀,如「Minecraft 登录被拒」)。
|
||||
pub what: String,
|
||||
/// HTTP 状态码。
|
||||
pub status: u16,
|
||||
/// `Content-Type`(服务端没给时为 `None`)。
|
||||
pub content_type: Option<String>,
|
||||
/// 正文形状。
|
||||
pub kind: BodyKind,
|
||||
/// 服务端文案(已脱敏、已截断)。
|
||||
pub server_message: Option<String>,
|
||||
/// 正文摘要(已脱敏,≤[`DIGEST_CHARS`] 字符)。
|
||||
pub digest: String,
|
||||
}
|
||||
|
||||
impl ResponseDetail {
|
||||
/// 细分机读码(**只增不改**):JSON 拒绝沿用 M31 的 `<step>_<status>`;
|
||||
/// 非 JSON 用新增后缀 `<step>_non_json`;空体用 `<step>_empty_body`。
|
||||
#[must_use]
|
||||
pub fn detail_code(&self) -> String {
|
||||
let suffix = match self.kind {
|
||||
BodyKind::Json => self.status.to_string(),
|
||||
BodyKind::Html | BodyKind::Text => "non_json".to_string(),
|
||||
BodyKind::Empty => "empty_body".to_string(),
|
||||
};
|
||||
format!("{}_{suffix}", self.step)
|
||||
}
|
||||
|
||||
/// 是否是「疑似网络拦截页」(HTML / 纯文本)。
|
||||
#[must_use]
|
||||
pub const fn is_interception(&self) -> bool {
|
||||
self.kind.is_interception()
|
||||
}
|
||||
|
||||
/// 「;服务端文案…;响应摘要…」片段(都没有时为空串,方便复用文案拼装)。
|
||||
#[must_use]
|
||||
pub fn note(&self) -> String {
|
||||
let mut note = String::new();
|
||||
if let Some(message) = &self.server_message {
|
||||
note.push_str(&format!(";服务端文案「{message}」"));
|
||||
}
|
||||
if !self.digest.is_empty() {
|
||||
note.push_str(&format!(";响应摘要:{}", self.digest));
|
||||
}
|
||||
note
|
||||
}
|
||||
|
||||
/// 人读一句话(**不含凭据**),403 的两种分流在这里分开。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> String {
|
||||
let mut text = format!("{}(HTTP {}", self.what, self.status);
|
||||
if let Some(content_type) = &self.content_type {
|
||||
text.push_str(&format!(",Content-Type: {content_type}"));
|
||||
}
|
||||
text.push(')');
|
||||
match self.kind {
|
||||
BodyKind::Json => text.push_str(":服务端返回了 JSON 拒绝"),
|
||||
BodyKind::Html | BodyKind::Text => {
|
||||
text.push_str(":响应非 JSON(可能网络拦截/网关页)");
|
||||
}
|
||||
BodyKind::Empty => text.push_str(":响应体为空(可能网络拦截/网关页)"),
|
||||
}
|
||||
text.push(';');
|
||||
text.push_str(advice(&self.step, self.kind));
|
||||
text.push_str(&self.note());
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
/// 下一步该往哪查(403 分流的核心:拦截页查网络/代理,JSON 拒绝查账号许可)。
|
||||
#[must_use]
|
||||
pub fn advice(step: &str, kind: BodyKind) -> &'static str {
|
||||
if kind.is_interception() {
|
||||
return "请检查网络或代理(关掉/换掉代理后再试,必要时用镜像方案)";
|
||||
}
|
||||
match step {
|
||||
"login_with_xbox" => "若网络正常,请检查该账号的 Minecraft 许可与所在区域",
|
||||
"entitlements" => "若网络正常,请检查该账号是否拥有 Minecraft Java 版",
|
||||
"profile" => "若网络正常,请去 minecraft.net 检查档案状态",
|
||||
_ => "请按服务端文案处理;如反复失败,检查网络代理是否生效",
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类一个非 2xx 应答。
|
||||
pub(crate) fn classify(reply: &Reply) -> BodyKind {
|
||||
if reply.body.is_some() {
|
||||
return BodyKind::Json;
|
||||
}
|
||||
let Some(raw) = reply
|
||||
.raw
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|raw| !raw.is_empty())
|
||||
else {
|
||||
return BodyKind::Empty;
|
||||
};
|
||||
let content_type = reply
|
||||
.content_type
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if content_type.contains("html") || looks_like_html(raw) {
|
||||
BodyKind::Html
|
||||
} else {
|
||||
BodyKind::Text
|
||||
}
|
||||
}
|
||||
|
||||
/// 正文是不是 HTML 文档(没给 `Content-Type` 时的兜底判断)。
|
||||
fn looks_like_html(raw: &str) -> bool {
|
||||
raw.trim_start().starts_with('<')
|
||||
}
|
||||
|
||||
/// 组装一条诊断(分类 + 服务端文案 + 摘要)。
|
||||
pub(crate) fn rejection(
|
||||
step: &str,
|
||||
reply: &Reply,
|
||||
what: &str,
|
||||
redactor: &Redactor,
|
||||
) -> ResponseDetail {
|
||||
ResponseDetail {
|
||||
step: step.to_string(),
|
||||
what: what.to_string(),
|
||||
status: reply.status,
|
||||
content_type: reply.content_type.clone(),
|
||||
kind: classify(reply),
|
||||
server_message: server_message(reply, redactor),
|
||||
digest: digest(reply, redactor),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 JSON 体里挑出服务端文案(去重后以 ` / ` 连接)。
|
||||
fn server_message(reply: &Reply, redactor: &Redactor) -> Option<String> {
|
||||
let body = reply.body.as_ref()?;
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for key in SERVER_MESSAGE_KEYS {
|
||||
let Some(text) = body.get(key).and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let text = one_line(text);
|
||||
if text.is_empty() || parts.iter().any(|part| part == &text) {
|
||||
continue;
|
||||
}
|
||||
parts.push(text);
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(truncate(&scrub(&parts.join(" / "), redactor), DIGEST_CHARS))
|
||||
}
|
||||
|
||||
/// 正文摘要:JSON 用紧凑文本,非 JSON 用原文快照;先脱敏再截断。
|
||||
fn digest(reply: &Reply, redactor: &Redactor) -> String {
|
||||
let raw = match (&reply.body, &reply.raw) {
|
||||
(Some(body), _) => body.to_string(),
|
||||
(None, Some(raw)) => raw.to_string(),
|
||||
(None, None) => return String::new(),
|
||||
};
|
||||
truncate(&scrub(&one_line(&raw), redactor), DIGEST_CHARS)
|
||||
}
|
||||
|
||||
/// 走完整套脱敏:[`Redactor::scrub`] 的三条规则(已登记 token + ≥64 不透明串 + 带点 token)。
|
||||
///
|
||||
/// M40 之前这里自己补了一条「带点 token」规则:`Redactor` 只按「连续不透明串 ≥64」判形状,
|
||||
/// 而 JWT(`header.payload.signature`)里有点号,把连续段切短,恰好躲过那条规则——服务端
|
||||
/// 把 token 回显在错误体里时就会原样进摘要。现在规则已上提到 `launch::redact`(全局底座),
|
||||
/// 这里只调共用实现,两处规则不会再漂。
|
||||
fn scrub(text: &str, redactor: &Redactor) -> String {
|
||||
redactor.scrub(text).into_owned()
|
||||
}
|
||||
|
||||
/// 压成一行(HTML 的分行会让错误消息难以阅读)。
|
||||
fn one_line(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut space = false;
|
||||
for ch in text.chars() {
|
||||
if ch.is_whitespace() {
|
||||
space = !out.is_empty();
|
||||
continue;
|
||||
}
|
||||
if std::mem::take(&mut space) {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 按字符截断(超长补 `…`)。
|
||||
fn truncate(text: &str, limit: usize) -> String {
|
||||
if text.chars().count() <= limit {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut out: String = text.chars().take(limit.saturating_sub(1)).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
/// 把诊断包成 [`AuthError::Response`](链上各步骤的统一出口)。
|
||||
pub(crate) fn response(step: &str, reply: &Reply, what: &str, redactor: &Redactor) -> AuthError {
|
||||
AuthError::Response(Box::new(rejection(step, reply, what, redactor)))
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! [`super`] 的单测:分类、服务端文案提取、摘要脱敏与 403 分流文案。
|
||||
|
||||
use super::{
|
||||
advice, classify, digest, one_line, response, server_message, truncate, BodyKind, DIGEST_CHARS,
|
||||
};
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::auth::http::Reply;
|
||||
use crate::auth::secret::{redactor_for, Secret};
|
||||
use crate::launch::redact::Redactor;
|
||||
use serde_json::json;
|
||||
|
||||
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
|
||||
|
||||
fn json_reply(status: u16, body: serde_json::Value) -> Reply {
|
||||
Reply {
|
||||
status,
|
||||
body: Some(body),
|
||||
content_type: Some("application/json".to_string()),
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_reply(status: u16, content_type: Option<&str>, raw: &str) -> Reply {
|
||||
Reply {
|
||||
status,
|
||||
body: None,
|
||||
content_type: content_type.map(str::to_string),
|
||||
raw: Some(raw.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_splits_json_html_text_and_empty() {
|
||||
assert_eq!(
|
||||
classify(&json_reply(403, json!({ "error": "x" }))),
|
||||
BodyKind::Json
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&text_reply(403, Some("text/html"), "<!DOCTYPE html>")),
|
||||
BodyKind::Html
|
||||
);
|
||||
// 没给 Content-Type 时靠正文形状兜底。
|
||||
assert_eq!(
|
||||
classify(&text_reply(403, None, "<html><body>blocked")),
|
||||
BodyKind::Html
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&text_reply(502, Some("text/plain"), "Bad Gateway")),
|
||||
BodyKind::Text
|
||||
);
|
||||
assert_eq!(classify(&text_reply(403, None, " ")), BodyKind::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_message_prefers_error_fields_and_dedups() {
|
||||
let redactor = Redactor::new();
|
||||
let reply = json_reply(
|
||||
403,
|
||||
json!({ "error": "UNAUTHORIZED", "errorMessage": "no license", "path": "/x" }),
|
||||
);
|
||||
let message = server_message(&reply, &redactor).expect("应有服务端文案");
|
||||
assert!(message.contains("no license"), "{message}");
|
||||
assert!(message.contains("UNAUTHORIZED"), "{message}");
|
||||
|
||||
let same = json_reply(403, json!({ "error": "DENIED", "errorMessage": "DENIED" }));
|
||||
assert_eq!(server_message(&same, &redactor).as_deref(), Some("DENIED"));
|
||||
assert!(server_message(&json_reply(403, json!({ "path": "/x" })), &redactor).is_none());
|
||||
assert!(server_message(&text_reply(500, None, "boom"), &redactor).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digest_is_redacted_and_capped() {
|
||||
let token = Secret::new("mc-access-token-value");
|
||||
let redactor = redactor_for([&token]);
|
||||
let reply = json_reply(
|
||||
403,
|
||||
json!({ "error": "DENIED", "echo": format!("Bearer {}", token.expose()) }),
|
||||
);
|
||||
let masked_digest = digest(&reply, &redactor);
|
||||
assert!(
|
||||
!masked_digest.contains("mc-access-token-value"),
|
||||
"{masked_digest}"
|
||||
);
|
||||
|
||||
let long = "blocked ".repeat(60);
|
||||
let capped = digest(&text_reply(500, Some("text/plain"), &long), &redactor);
|
||||
assert!(capped.chars().count() <= DIGEST_CHARS);
|
||||
assert!(capped.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_runs_are_masked_even_without_known_secret() {
|
||||
let masked = digest(&text_reply(401, Some("text/plain"), JWT), &Redactor::new());
|
||||
assert!(!masked.contains("eyJhbGci"), "{masked}");
|
||||
assert!(masked.contains("redacted"), "{masked}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_tokens_are_masked_by_the_shared_redactor() {
|
||||
// 规则本体在 `launch::redact`(M40 上提),这里只验「auth 摘要走的是同一份实现」。
|
||||
let redactor = Redactor::new();
|
||||
assert!(!redactor.scrub(JWT).contains("eyJhbGci"), "JWT 应被打码");
|
||||
// 普通文案里的点不会触发(分段太短)。
|
||||
assert_eq!(
|
||||
redactor.scrub("Service.Unavailable.today"),
|
||||
"Service.Unavailable.today"
|
||||
);
|
||||
let masked = redactor.scrub(&format!("token={JWT}&end")).into_owned();
|
||||
assert!(!masked.contains("eyJhbGci"), "{masked}");
|
||||
assert!(masked.ends_with("&end"), "{masked}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_line_collapses_whitespace() {
|
||||
assert_eq!(one_line(" a\n\n b\tc "), "a b c");
|
||||
assert_eq!(truncate("abcdef", 4), "abc…");
|
||||
assert_eq!(truncate("abc", 4), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_codes_and_advice_split_network_from_license() {
|
||||
let redactor = Redactor::new();
|
||||
let blocked = response(
|
||||
"login_with_xbox",
|
||||
&text_reply(403, Some("text/html"), "<html>blocked</html>"),
|
||||
"Minecraft 登录被拒",
|
||||
&redactor,
|
||||
);
|
||||
let AuthError::Response(detail) = &blocked else {
|
||||
panic!("应是 Response,实际 {blocked:?}");
|
||||
};
|
||||
assert_eq!(detail.detail_code(), "login_with_xbox_non_json");
|
||||
assert!(
|
||||
detail.message().contains("网络拦截"),
|
||||
"{}",
|
||||
detail.message()
|
||||
);
|
||||
assert!(detail.message().contains("代理"), "{}", detail.message());
|
||||
|
||||
let denied = response(
|
||||
"login_with_xbox",
|
||||
&json_reply(
|
||||
403,
|
||||
json!({ "error": "UNAUTHORIZED", "errorMessage": "nope" }),
|
||||
),
|
||||
"Minecraft 登录被拒",
|
||||
&redactor,
|
||||
);
|
||||
let AuthError::Response(detail) = &denied else {
|
||||
panic!("应是 Response,实际 {denied:?}");
|
||||
};
|
||||
assert_eq!(detail.detail_code(), "login_with_xbox_403");
|
||||
assert!(detail.message().contains("许可"), "{}", detail.message());
|
||||
assert!(!detail.message().contains("代理"), "{}", detail.message());
|
||||
|
||||
let empty = response(
|
||||
"profile",
|
||||
&text_reply(500, None, ""),
|
||||
"拉取档案被拒",
|
||||
&redactor,
|
||||
);
|
||||
let AuthError::Response(detail) = &empty else {
|
||||
panic!("应是 Response,实际 {empty:?}");
|
||||
};
|
||||
assert_eq!(detail.detail_code(), "profile_empty_body");
|
||||
assert!(!advice("xbl", BodyKind::Html).is_empty());
|
||||
assert!(!advice("xbl", BodyKind::Json).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! 认证错误:把 HMCL 的异常树压成「调用方能分流」的一层(`analyse/04` §2.6 / §4.5)。
|
||||
//!
|
||||
//! 三条设计:
|
||||
//! 1. **`CredentialExpired` 是唯一「要人再登录」的信号**(承 HMCL `CredentialExpiredException`),
|
||||
//! 其余错误都不该弹交互;
|
||||
//! 2. **取消 / 超时 / 被拒 三分**(mission 明示的错误分流):用户关窗口 = `Canceled`,
|
||||
//! 设备码 15 分钟硬超时 = `Timeout`,`access_denied` = `Denied`;
|
||||
//! 3. **文案里永不含凭据**:错误只描述发生了什么,token 一律不进 message。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::auth::body::ResponseDetail;
|
||||
|
||||
/// Xbox Live 错误码:账号被封(`MicrosoftService.BANNED`)。
|
||||
pub const XERR_BANNED: u32 = 2_148_916_227;
|
||||
/// 没有 Xbox 账号(需先建档案)。
|
||||
pub const XERR_MISSING_XBOX_ACCOUNT: u32 = 2_148_916_233;
|
||||
/// 地区不可用。
|
||||
pub const XERR_COUNTRY_UNAVAILABLE: u32 = 2_148_916_235;
|
||||
/// 儿童账号未加入家庭组。
|
||||
pub const XERR_ADD_FAMILY: u32 = 2_148_916_238;
|
||||
|
||||
/// 认证失败。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthError {
|
||||
/// 网络层失败(DNS/连接/TLS/超时以外的 IO)。
|
||||
Network {
|
||||
/// 目标 URL(不含 query 里的凭据)。
|
||||
url: String,
|
||||
/// 底层描述。
|
||||
detail: String,
|
||||
},
|
||||
/// 超时:连接超时、整体超时,或设备码流程等满 `expires_in`/15 分钟。
|
||||
Timeout {
|
||||
/// 在等什么。
|
||||
what: String,
|
||||
},
|
||||
/// 用户主动取消(关窗口、Ctrl+C、`access_denied` 也归这里)。
|
||||
Canceled,
|
||||
/// 凭据过期/被撤销,必须重新交互式登录(`AADSTS70000` 族)。
|
||||
CredentialExpired,
|
||||
/// 服务端明确拒绝(OAuth `error` + `error_description`)。
|
||||
Denied {
|
||||
/// OAuth 错误码(`invalid_grant` / `authorization_declined` …)。
|
||||
code: String,
|
||||
/// 服务端描述。
|
||||
description: String,
|
||||
},
|
||||
/// 链上某一步拿到非 2xx,且带回了正文诊断(M38)。
|
||||
///
|
||||
/// 与 [`Self::Denied`] 同一分流层(`code()` 仍是 `denied`,向后兼容 M31 的调用方),
|
||||
/// 细分码看 [`AuthError::detail_code`]:JSON 拒绝沿用 `<step>_<status>`,
|
||||
/// 拦截页(HTML/纯文本)是新增的 `<step>_non_json`,空体是 `<step>_empty_body`。
|
||||
///
|
||||
/// 载荷装箱:`ResponseDetail` 有 7 个字段(含 4 个字符串),不装箱会把整个枚举撑到
|
||||
/// 128 字节以上,`Result<T, AuthError>` 的每个 Ok 也跟着变胖(clippy `result_large_err`)。
|
||||
Response(Box<ResponseDetail>),
|
||||
/// Xbox Live / XSTS 业务错误码(`XErr`)。
|
||||
Xbox {
|
||||
/// `XErr` 数值。
|
||||
code: u32,
|
||||
},
|
||||
/// 响应结构不合法(JSON 缺字段、类型不对、profile 被换掉)。
|
||||
Malformed(String),
|
||||
/// 凭据/元数据落盘或读取失败。
|
||||
Store {
|
||||
/// 涉及的路径。
|
||||
path: String,
|
||||
/// 底层描述。
|
||||
detail: String,
|
||||
},
|
||||
/// 找不到指定账户。
|
||||
NotFound {
|
||||
/// 用户给的查找键。
|
||||
account: String,
|
||||
},
|
||||
/// 账户重复(同名同轨已存在)。
|
||||
Conflict {
|
||||
/// 冲突的账户名。
|
||||
account: String,
|
||||
},
|
||||
/// 该轨/该能力当前不可用:**未配置**(如缺 Azure client id)或本期未实现
|
||||
/// (如 CLI 上的 authlib 密码登录)。
|
||||
///
|
||||
/// 载荷必须**自带完整的人读句子**(「未配置 + 怎么配」/「未实现 + 为什么」),
|
||||
/// 本层不再统一拼「本期未支持:」这类前缀——同一层里两种情况完全不同的事,
|
||||
/// 硬拼一个前缀会让「没配置」被读成「没实现」(M117 用户实跑踩到)。
|
||||
/// 机读码仍是 `unsupported`([`Self::code`]),调用方分流不受影响。
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
impl AuthError {
|
||||
/// 稳定机读码(CLI `--json` 与日志都用它,不随文案变)。
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Network { .. } => "network",
|
||||
Self::Timeout { .. } => "timeout",
|
||||
Self::Canceled => "canceled",
|
||||
Self::CredentialExpired => "credential_expired",
|
||||
Self::Denied { .. } => "denied",
|
||||
// 与 `Denied` 同一粗分流层:M31 时代的调用方(CLI 提示、launch 身份解析)无需改。
|
||||
Self::Response(_) => "denied",
|
||||
Self::Xbox { .. } => "xbox",
|
||||
Self::Malformed(_) => "malformed",
|
||||
Self::Store { .. } => "store",
|
||||
Self::NotFound { .. } => "not_found",
|
||||
Self::Conflict { .. } => "conflict",
|
||||
Self::Unsupported(_) => "unsupported",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否是「必须重新登录」的信号(唯一该弹交互的一类)。
|
||||
#[must_use]
|
||||
pub const fn is_credential_expired(&self) -> bool {
|
||||
matches!(self, Self::CredentialExpired)
|
||||
}
|
||||
|
||||
/// 是否属于「网络/服务端抽风,可重试或跳过」的一类。
|
||||
#[must_use]
|
||||
pub const fn is_transient(&self) -> bool {
|
||||
match self {
|
||||
Self::Network { .. } | Self::Timeout { .. } | Self::Xbox { .. } => true,
|
||||
// 拦截页/空体多半是网络侧问题;JSON 拒绝是服务端明确表态,重试无意义。
|
||||
Self::Response(detail) => detail.kind.is_interception(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 细分机读码(M38 新增):OAuth 拒绝返回 `<step>` 里带的错误码,链上非 2xx 返回
|
||||
/// `<step>_<status>` / `<step>_non_json` / `<step>_empty_body`,其余回落到 [`Self::code`]。
|
||||
///
|
||||
/// 与 [`Self::code`] 的分工:`code()` 是**分流层**(决定弹不弹交互、能不能重试),
|
||||
/// 这个方法给日志与 `--json` 的**排障层**用。既有码一律不改,只增。
|
||||
#[must_use]
|
||||
pub fn detail_code(&self) -> String {
|
||||
match self {
|
||||
Self::Response(detail) => detail.detail_code(),
|
||||
Self::Denied { code, .. } => code.clone(),
|
||||
other => other.code().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络错误的构造(`url` 只放 host+path,别把 query 里的 code 带进来)。
|
||||
#[must_use]
|
||||
pub fn network(url: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self::Network {
|
||||
url: url.into(),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 落盘错误的构造。
|
||||
#[must_use]
|
||||
pub fn store(path: impl fmt::Display, detail: impl Into<String>) -> Self {
|
||||
Self::Store {
|
||||
path: path.to_string(),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读一句话(**不含凭据**)。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
Self::Network { url, detail } => format!("网络请求失败({url}):{detail}"),
|
||||
Self::Timeout { what } => format!("等待超时:{what}"),
|
||||
Self::Canceled => "登录已取消".to_string(),
|
||||
Self::CredentialExpired => "凭据已过期,需要重新登录".to_string(),
|
||||
Self::Denied { code, description } => {
|
||||
format!("服务端拒绝({code}):{description}")
|
||||
}
|
||||
Self::Response(detail) => detail.message(),
|
||||
Self::Xbox { code } => format!("Xbox 授权失败(XErr {code}):{}", xbox_hint(*code)),
|
||||
Self::Malformed(detail) => format!("响应不符合预期:{detail}"),
|
||||
Self::Store { path, detail } => format!("账户数据读写失败({path}):{detail}"),
|
||||
Self::NotFound { account } => format!("找不到账户 `{account}`"),
|
||||
Self::Conflict { account } => format!("账户 `{account}` 已存在"),
|
||||
Self::Unsupported(detail) => detail.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AuthError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AuthError {}
|
||||
|
||||
/// `XErr` 码 → 中文说明(承 `analyse/04` §5.2 的四码表)。
|
||||
#[must_use]
|
||||
pub fn xbox_hint(code: u32) -> &'static str {
|
||||
match code {
|
||||
XERR_BANNED => "该微软账号已被 Xbox Live 封禁",
|
||||
XERR_MISSING_XBOX_ACCOUNT => "该微软账号还没有 Xbox 档案,请先去 xbox.com 创建",
|
||||
XERR_COUNTRY_UNAVAILABLE => "该账号所在地区不被 Xbox Live 支持",
|
||||
XERR_ADD_FAMILY => "儿童账号需要先加入 Microsoft 家庭组",
|
||||
_ => "Xbox Live 拒绝了本次授权(可能无 Xbox 档案或账号受限)",
|
||||
}
|
||||
}
|
||||
|
||||
/// 结果别名。
|
||||
pub type AuthResult<T> = Result<T, AuthError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{xbox_hint, AuthError, XERR_ADD_FAMILY};
|
||||
|
||||
#[test]
|
||||
fn codes_are_stable() {
|
||||
assert_eq!(AuthError::Canceled.code(), "canceled");
|
||||
assert_eq!(AuthError::CredentialExpired.code(), "credential_expired");
|
||||
assert!(AuthError::Xbox {
|
||||
code: XERR_ADD_FAMILY
|
||||
}
|
||||
.is_transient());
|
||||
assert!(!AuthError::Canceled.is_transient());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_carry_no_secret_shape() {
|
||||
let error = AuthError::Denied {
|
||||
code: "invalid_grant".to_string(),
|
||||
description: "AADSTS70000: bad redirect".to_string(),
|
||||
};
|
||||
assert!(error.message().contains("AADSTS70000"));
|
||||
assert!(AuthError::Xbox { code: 2148916238 }
|
||||
.message()
|
||||
.contains("家庭组"));
|
||||
assert!(!xbox_hint(0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_shares_denied_coarse_code_but_has_its_own_detail_code() {
|
||||
let error = AuthError::Response(Box::new(super::ResponseDetail {
|
||||
step: "login_with_xbox".to_string(),
|
||||
what: "Minecraft 登录被拒".to_string(),
|
||||
status: 403,
|
||||
content_type: Some("text/html".to_string()),
|
||||
kind: crate::auth::body::BodyKind::Html,
|
||||
server_message: None,
|
||||
digest: String::new(),
|
||||
}));
|
||||
// 粗分流层向后兼容:仍是 `denied`;排障层才是细分码。
|
||||
assert_eq!(error.code(), "denied");
|
||||
assert_eq!(error.detail_code(), "login_with_xbox_non_json");
|
||||
assert!(error.is_transient(), "拦截页可以重试");
|
||||
assert_eq!(
|
||||
AuthError::Canceled.detail_code(),
|
||||
"canceled",
|
||||
"没有细分的错误回落到 code()"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 认证用的 HTTP 薄封装(三条轨共用):**复用既有 download 引擎的客户端基座**
|
||||
//! (mission Context:网络操作走既有 HTTP 基座),只补三件认证需要的东西:
|
||||
//! 表单/JSON 体、Bearer 头、单请求整体超时。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//! 1. **响应体不原样进错误文案**——OAuth 的 token 响应体本身就是凭据,把它塞进
|
||||
//! `Malformed` 的 message 就等于把 token 写进日志(`analyse/04` §11 观察项 9 的坑);
|
||||
//! 因此解析失败只说「不是合法 JSON」,贴原文的唯一通道是
|
||||
//! [`crate::auth::body`] 的脱敏摘要(M38:仅非 2xx、≤200 字符、过 Redactor)。
|
||||
//! 2. 状态码不做归类,**原样交给调用方**:XSTS 的 401 带 `XErr`、`/minecraft/profile` 的
|
||||
//! 404 要复判 license,都是「非 200 但有语义」的分支,提前归一化会丢掉信息。
|
||||
//! 3. **诊断留证,但不留凭据**(M38):`Reply` 额外携带 `Content-Type` 与非 JSON 正文的快照
|
||||
//! ([`RAW_PREVIEW_BYTES`] 字节封顶),诊断由 [`crate::auth::body`] 分类/脱敏后再进错误消息。
|
||||
//! 原文快照**只在 JSON 解析失败时**保留,所以 OAuth 成功响应体不会被复制进第二个字段。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
/// 单请求整体超时(设备码轮询的每个请求都远快于此)。
|
||||
pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 非 JSON 正文保留的快照上限(字节):够判断「是不是 HTML」并抓前几句文案。
|
||||
pub(crate) const RAW_PREVIEW_BYTES: usize = 512;
|
||||
|
||||
/// 一次应答:状态码 + 可解析的 JSON 体 + 诊断用的头/正文快照。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Reply {
|
||||
/// HTTP 状态码。
|
||||
pub status: u16,
|
||||
/// 解析成功时的 JSON 体(空体/非 JSON 为 `None`)。
|
||||
pub body: Option<Value>,
|
||||
/// `Content-Type` 原样(诊断文案与分流判断用;缺失为 `None`)。
|
||||
pub content_type: Option<String>,
|
||||
/// 非 JSON 正文的前 [`RAW_PREVIEW_BYTES`] 字节(有损 UTF-8;JSON 解析成功时为 `None`)。
|
||||
pub raw: Option<String>,
|
||||
}
|
||||
|
||||
impl Reply {
|
||||
/// 是否 2xx。
|
||||
pub(crate) fn is_success(&self) -> bool {
|
||||
(200..300).contains(&self.status)
|
||||
}
|
||||
|
||||
/// 取一个字符串字段。
|
||||
pub(crate) fn text(&self, path: &str) -> Option<&str> {
|
||||
self.body.as_ref()?.get(path)?.as_str()
|
||||
}
|
||||
|
||||
/// 取一个 u64 字段(数字或数字字符串都接受)。
|
||||
pub(crate) fn number(&self, path: &str) -> Option<u64> {
|
||||
let value = self.body.as_ref()?.get(path)?;
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_str().and_then(|raw| raw.trim().parse().ok()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 表单 `POST`(OAuth 的设备码/token 端点)。
|
||||
pub(crate) async fn post_form(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
form: Vec<(&str, String)>,
|
||||
) -> AuthResult<Reply> {
|
||||
let request = client
|
||||
.post(url)
|
||||
.header(ACCEPT, "application/json")
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.form(&form);
|
||||
send(request, url).await
|
||||
}
|
||||
|
||||
/// JSON `POST`(XBL / XSTS / login_with_xbox)。
|
||||
pub(crate) async fn post_json(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
body: &Value,
|
||||
bearer: Option<&Secret>,
|
||||
) -> AuthResult<Reply> {
|
||||
let text = serde_json::to_string(body)
|
||||
.map_err(|error| AuthError::Malformed(format!("请求体序列化失败:{error}")))?;
|
||||
let mut request = client
|
||||
.post(url)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(ACCEPT, "application/json")
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.body(text);
|
||||
if let Some(token) = bearer {
|
||||
request = request.header(AUTHORIZATION, format!("Bearer {}", token.expose()));
|
||||
}
|
||||
send(request, url).await
|
||||
}
|
||||
|
||||
/// 带 Bearer 的 `GET`(entitlements / profile)。
|
||||
pub(crate) async fn get_json(client: &Client, url: &str, bearer: &Secret) -> AuthResult<Reply> {
|
||||
let request = client
|
||||
.get(url)
|
||||
.header(ACCEPT, "application/json")
|
||||
.header(AUTHORIZATION, format!("Bearer {}", bearer.expose()))
|
||||
.timeout(REQUEST_TIMEOUT);
|
||||
send(request, url).await
|
||||
}
|
||||
|
||||
/// 发请求并解析 JSON(无凭据泄漏的错误映射)。
|
||||
async fn send(request: reqwest::RequestBuilder, url: &str) -> AuthResult<Reply> {
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| http_error(url, &error))?;
|
||||
let status = response.status().as_u16();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| http_error(url, &error))?;
|
||||
let body = if bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
serde_json::from_slice::<Value>(&bytes).ok()
|
||||
};
|
||||
// 只有解析失败(HTML 拦截页 / 纯文本 / 坏 JSON)才留原文快照:
|
||||
// 解析成功的响应体可能含 token,不留第二份副本。
|
||||
let raw = match (&body, bytes.is_empty()) {
|
||||
(None, false) => Some(preview(&bytes)),
|
||||
_ => None,
|
||||
};
|
||||
Ok(Reply {
|
||||
status,
|
||||
body,
|
||||
content_type,
|
||||
raw,
|
||||
})
|
||||
}
|
||||
|
||||
/// 取正文前 [`RAW_PREVIEW_BYTES`] 字节(有损解码,截断时补 `…`)。
|
||||
fn preview(bytes: &[u8]) -> String {
|
||||
let truncated = bytes.len() > RAW_PREVIEW_BYTES;
|
||||
let head = &bytes[..bytes.len().min(RAW_PREVIEW_BYTES)];
|
||||
let mut text = String::from_utf8_lossy(head).into_owned();
|
||||
if truncated {
|
||||
text.push('…');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// `reqwest::Error` → [`AuthError`](超时与非超时分开,文案只带 host+path)。
|
||||
fn http_error(url: &str, error: &reqwest::Error) -> AuthError {
|
||||
let target = url.split('?').next().unwrap_or(url).to_string();
|
||||
if error.is_timeout() {
|
||||
AuthError::Timeout {
|
||||
what: format!("请求 {target}"),
|
||||
}
|
||||
} else {
|
||||
AuthError::network(target, error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{preview, Reply, RAW_PREVIEW_BYTES};
|
||||
use serde_json::json;
|
||||
|
||||
fn reply(status: u16, body: Option<serde_json::Value>) -> Reply {
|
||||
Reply {
|
||||
status,
|
||||
body,
|
||||
content_type: Some("application/json".to_string()),
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_reads_text_and_numbers() {
|
||||
let sample = reply(
|
||||
200,
|
||||
Some(json!({ "user_code": "ABC", "expires_in": 900, "interval": "5" })),
|
||||
);
|
||||
assert!(sample.is_success());
|
||||
assert_eq!(sample.text("user_code"), Some("ABC"));
|
||||
assert_eq!(sample.number("expires_in"), Some(900));
|
||||
assert_eq!(sample.number("interval"), Some(5));
|
||||
assert_eq!(sample.number("missing"), None);
|
||||
assert_eq!(sample.text("missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_success_is_not_success() {
|
||||
assert!(!reply(401, None).is_success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_caps_and_marks_truncation() {
|
||||
let short = preview(b"Bad Gateway");
|
||||
assert_eq!(short, "Bad Gateway");
|
||||
let long = preview(&vec![b'x'; RAW_PREVIEW_BYTES + 64]);
|
||||
assert!(long.ends_with('…'));
|
||||
assert_eq!(long.trim_end_matches('…').len(), RAW_PREVIEW_BYTES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! auth 失败事件的落盘(`.ULL/logs/auth.jsonl`,M38)。
|
||||
//!
|
||||
//! 起因:MSA 真机验收两轮 403,`ull accounts add msa` 失败后终端只有一行文案,
|
||||
//! 验收人拿不到状态码/正文摘要去对账。这里把**失败**写成一行一条 JSONL:
|
||||
//! 便携模式数据根还没初始化时会先补建 `logs/` 分区,403 的细节不再随终端滚走。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//! 1. **只记失败**,不记成功(成功路径的凭据载荷一律不落日志);
|
||||
//! 2. **记的是诊断字段**(`code` / `detail_code` / `status` / `content_type` / `body_kind` /
|
||||
//! 已脱敏的 `server_message`、`digest`),没有任何 token/密码字段;
|
||||
//! 3. **落盘是尽力而为**:写日志失败绝不让登录本身失败(调用方忽略返回值)。
|
||||
//!
|
||||
//! 数据根从哪来:默认走 `platform::data_dir` 的 **Portable** 解析(exe 同目录的 `.ULL`,
|
||||
//! 跟随 `redirect.json`)。宿主若用 `--data-dir` 等非便携模式,应显式
|
||||
//! [`AuthJournal::in_logs_dir`] 后交给 [`crate::auth::MicrosoftAuth::with_journal`]。
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::body::BodyKind;
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::launch::redact::Redactor;
|
||||
use crate::platform::data_dir::{DataDirRequest, Resolver};
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
use crate::platform::paths::Platform;
|
||||
|
||||
/// 认证失败事件文件名(在 `.ULL/logs/` 下)。
|
||||
pub const AUTH_LOG_FILE: &str = "auth.jsonl";
|
||||
|
||||
/// 一行认证失败事件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthFailureEvent {
|
||||
/// Unix 毫秒。
|
||||
pub ts_ms: u64,
|
||||
/// 触发入口(`device_code_login` / `refresh` / `validate`)。
|
||||
pub scope: String,
|
||||
/// 链上的步骤(`AuthError::Response` 才有)。
|
||||
pub step: Option<String>,
|
||||
/// 粗分流码([`AuthError::code`])。
|
||||
pub code: String,
|
||||
/// 细分码([`AuthError::detail_code`])。
|
||||
pub detail_code: String,
|
||||
/// HTTP 状态码(链上拒绝才有)。
|
||||
pub status: Option<u16>,
|
||||
/// `Content-Type`(链上拒绝才有)。
|
||||
pub content_type: Option<String>,
|
||||
/// 正文形状(链上拒绝才有)。
|
||||
pub body_kind: Option<BodyKind>,
|
||||
/// 服务端文案(已脱敏)。
|
||||
pub server_message: Option<String>,
|
||||
/// 正文摘要(已脱敏,≤200 字符)。
|
||||
pub digest: Option<String>,
|
||||
/// 是否可重试([`AuthError::is_transient`])。
|
||||
pub transient: bool,
|
||||
/// 人读一句话(已脱敏,不含凭据)。
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl AuthFailureEvent {
|
||||
/// 从一条错误构造(`scope` 说明是哪个入口失败的)。
|
||||
#[must_use]
|
||||
pub fn of(scope: &str, error: &AuthError) -> Self {
|
||||
let detail = match error {
|
||||
AuthError::Response(detail) => Some(detail.as_ref()),
|
||||
_ => None,
|
||||
};
|
||||
let scrub = |text: &str| Redactor::new().scrub(text).into_owned();
|
||||
Self {
|
||||
ts_ms: crate::auth::microsoft::now_ms(),
|
||||
scope: scope.to_string(),
|
||||
step: detail.map(|detail| detail.step.clone()),
|
||||
code: error.code().to_string(),
|
||||
detail_code: error.detail_code(),
|
||||
status: detail.map(|detail| detail.status),
|
||||
content_type: detail.and_then(|detail| detail.content_type.clone()),
|
||||
body_kind: detail.map(|detail| detail.kind),
|
||||
server_message: detail
|
||||
.and_then(|detail| detail.server_message.as_deref())
|
||||
.map(scrub),
|
||||
digest: detail.map(|detail| scrub(&detail.digest)),
|
||||
transient: error.is_transient(),
|
||||
message: scrub(&error.message()),
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON 形状(字段名稳定,给验收/排障工具读)。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"ts_ms": self.ts_ms,
|
||||
"level": "error",
|
||||
"event": "auth_failure",
|
||||
"scope": self.scope,
|
||||
"step": self.step,
|
||||
"code": self.code,
|
||||
"detail_code": self.detail_code,
|
||||
"status": self.status,
|
||||
"content_type": self.content_type,
|
||||
"body_kind": self.body_kind.map(BodyKind::as_str),
|
||||
"server_message": self.server_message,
|
||||
"digest": self.digest,
|
||||
"transient": self.transient,
|
||||
"message": self.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 认证失败日志(JSONL,一行一条)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthJournal {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl AuthJournal {
|
||||
/// 落在某个 `logs/` 分区下(`<logs>/auth.jsonl`)。
|
||||
#[must_use]
|
||||
pub fn in_logs_dir(logs: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
path: logs.into().join(AUTH_LOG_FILE),
|
||||
}
|
||||
}
|
||||
|
||||
/// 直接指定文件路径。
|
||||
#[must_use]
|
||||
pub fn at(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
/// 便携模式默认位置(exe 同目录 `.ULL/logs/auth.jsonl`,跟随 `redirect.json`)。
|
||||
///
|
||||
/// 拿不到 exe 目录(极少数嵌入场景)时返回 `None`——没有可写的日志根就不写。
|
||||
#[must_use]
|
||||
pub fn portable_default() -> Option<Self> {
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|path| path.parent().map(Path::to_path_buf))?;
|
||||
let resolver = Resolver::new(Platform::current(), Some(exe_dir));
|
||||
let resolved = resolver.resolve_default(&DataDirRequest::default()).ok()?;
|
||||
Some(Self::in_logs_dir(
|
||||
LauncherLayout::new(resolved.launcher_home).logs(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 日志文件路径。
|
||||
#[must_use]
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// 追加一条事件(父目录不存在时先建)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 建目录或追加失败;调用方按「尽力而为」处理即可。
|
||||
pub fn record(&self, event: &AuthFailureEvent) -> std::io::Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut line = serde_json::to_string(&event.to_json()).unwrap_or_default();
|
||||
line.push('\n');
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
file.write_all(line.as_bytes())?;
|
||||
file.flush()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AuthFailureEvent, AuthJournal};
|
||||
use crate::auth::body::{BodyKind, ResponseDetail};
|
||||
use crate::auth::error::AuthError;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-auth-journal-{tag}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|since| since.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建临时目录");
|
||||
dir
|
||||
}
|
||||
|
||||
fn blocked_error() -> AuthError {
|
||||
AuthError::Response(Box::new(ResponseDetail {
|
||||
step: "login_with_xbox".to_string(),
|
||||
what: "Minecraft 登录被拒".to_string(),
|
||||
status: 403,
|
||||
content_type: Some("text/html".to_string()),
|
||||
kind: BodyKind::Html,
|
||||
server_message: None,
|
||||
digest: "<html>blocked</html>".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_carries_structured_diagnostics() {
|
||||
let event = AuthFailureEvent::of("device_code_login", &blocked_error());
|
||||
assert_eq!(event.scope, "device_code_login");
|
||||
assert_eq!(event.step.as_deref(), Some("login_with_xbox"));
|
||||
assert_eq!(event.code, "denied");
|
||||
assert_eq!(event.detail_code, "login_with_xbox_non_json");
|
||||
assert_eq!(event.status, Some(403));
|
||||
assert_eq!(event.content_type.as_deref(), Some("text/html"));
|
||||
assert_eq!(event.body_kind, Some(BodyKind::Html));
|
||||
assert!(event.transient);
|
||||
let value = event.to_json();
|
||||
assert_eq!(value["event"], "auth_failure");
|
||||
assert_eq!(value["level"], "error");
|
||||
assert_eq!(value["body_kind"], "html");
|
||||
assert!(value["message"]
|
||||
.as_str()
|
||||
.is_some_and(|text| !text.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chain_errors_still_record() {
|
||||
let event = AuthFailureEvent::of("refresh", &AuthError::Canceled);
|
||||
assert_eq!(event.detail_code, "canceled");
|
||||
assert_eq!(event.step, None);
|
||||
assert_eq!(event.status, None);
|
||||
assert!(!event.transient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_initializes_the_logs_dir_and_appends_one_line() {
|
||||
let root = temp_dir("init");
|
||||
let logs = root.join("logs");
|
||||
let journal = AuthJournal::in_logs_dir(&logs);
|
||||
assert!(!logs.exists(), "初始不该存在(模拟未初始化的数据根)");
|
||||
journal
|
||||
.record(&AuthFailureEvent::of("device_code_login", &blocked_error()))
|
||||
.expect("写日志");
|
||||
journal
|
||||
.record(&AuthFailureEvent::of("refresh", &AuthError::Canceled))
|
||||
.expect("写日志");
|
||||
let text = std::fs::read_to_string(journal.path()).expect("读日志");
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "一行一条:{text}");
|
||||
for line in lines {
|
||||
let value: serde_json::Value = serde_json::from_str(line).expect("每行都是合法 JSON");
|
||||
assert_eq!(value["event"], "auth_failure");
|
||||
}
|
||||
// 日志里不该出现任何凭据字段名。
|
||||
for forbidden in ["access_token", "refresh_token", "password", "token"] {
|
||||
assert!(!text.contains(forbidden), "日志出现了 {forbidden}:{text}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_paths_are_layout_based() {
|
||||
let journal = AuthJournal::in_logs_dir("/tmp/ull-logs");
|
||||
assert!(journal.path().ends_with("auth.jsonl"));
|
||||
let explicit = AuthJournal::at("/tmp/x/custom.jsonl");
|
||||
assert_eq!(explicit.path(), std::path::Path::new("/tmp/x/custom.jsonl"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! 授权码流(`research/08` §3 方案 A):授权 URL 构造 + code 换 token + 整链编排。
|
||||
//!
|
||||
//! 与设备码流的分工:**只多一跳**——拿 MS access_token 的方式不同,往后的
|
||||
//! XBL → XSTS → login_with_xbox → entitlements → profile 与刷新链完全复用
|
||||
//! ([`super::chain::complete_chain`]),所以两条流的凭据存储与刷新语义天然一致。
|
||||
//!
|
||||
//! # 三个硬性细节(都是「抄错就失败」的那一类)
|
||||
//!
|
||||
//! 1. **租户 `consumers`**:授权页与 token 端点都走 `login.microsoftonline.com/consumers/…`,
|
||||
//! 换成 `common`/AAD 租户 id 时 `XboxLive.signin` 直接被拒(`research/08` §2 约束 1)。
|
||||
//! 2. **重定向 URI 严格一致**:`http://localhost:{port}/auth` —— 授权 URL 里的、
|
||||
//! token 请求体里的、本地服务器实际监听的必须是**同一个字符串**(含端口与路径),
|
||||
//! 所以本模块只从 [`RedirectUri`] 取这一个来源。
|
||||
//! 3. **`prompt=select_account`**:不带这个参数,用户会被静默塞进上一次的账号(坑 6)。
|
||||
//!
|
||||
//! scope 与设备码流共用 [`super::flow::SCOPE`](`XboxLive.signin offline_access`)。
|
||||
|
||||
use std::fmt;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::auth::error::AuthResult;
|
||||
use crate::auth::http::post_form;
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
use super::callback::{CallbackServer, REDIRECT_PATH};
|
||||
use super::chain::complete_chain;
|
||||
use super::endpoints::MicrosoftEndpoints;
|
||||
use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE};
|
||||
use super::now_ms;
|
||||
use super::session::MicrosoftSession;
|
||||
|
||||
/// 授权码 grant 类型。
|
||||
pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code";
|
||||
/// 默认等回调的时间预算(用户要开浏览器、登录、点同意)。
|
||||
pub const DEFAULT_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// 默认的端口绑定尝试次数。
|
||||
pub const DEFAULT_BIND_ATTEMPTS: u8 = 3;
|
||||
|
||||
/// 回填到授权 URL 与 token 请求里的重定向 URI(**唯一来源**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RedirectUri {
|
||||
port: u16,
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl RedirectUri {
|
||||
/// 按实际绑定的端口构造 `http://localhost:{port}/auth`。
|
||||
///
|
||||
/// 用 `localhost` 而不是 `127.0.0.1`:微软对两者处理不一致(`research/08` 坑 5)。
|
||||
#[must_use]
|
||||
pub fn new(port: u16) -> Self {
|
||||
Self {
|
||||
port,
|
||||
url: format!("http://localhost:{port}{REDIRECT_PATH}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 端口。
|
||||
#[must_use]
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// URI 字面量(授权 URL 与 token 请求共用,保证逐字节一致)。
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RedirectUri {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.url)
|
||||
}
|
||||
}
|
||||
|
||||
/// 授权码流的运行参数(生产用 [`AuthCodePolicy::default`],测试压短超时/固定端口)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthCodePolicy {
|
||||
/// 等浏览器回调的上限。
|
||||
pub callback_timeout: Duration,
|
||||
/// 端口绑定尝试次数(`>= 1`)。
|
||||
pub bind_attempts: u8,
|
||||
/// 固定端口(`None` = 由内核分配,生产路径)。
|
||||
pub port: Option<u16>,
|
||||
/// CSRF 用的 `state`(`None` = 每次随机生成)。
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AuthCodePolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
callback_timeout: DEFAULT_CALLBACK_TIMEOUT,
|
||||
bind_attempts: DEFAULT_BIND_ATTEMPTS,
|
||||
port: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 要宿主做的事:打开浏览器(core 不碰 UI:谁来开由 CLI/GUI 决定)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthCodePrompt {
|
||||
/// 授权页 URL(宿主交给系统默认浏览器;也可展示给用户手动打开)。
|
||||
pub authorization_url: String,
|
||||
/// 本地回调地址(展示/排障用;与授权 URL 里的 `redirect_uri` 同源)。
|
||||
pub redirect_uri: String,
|
||||
/// 最多等多久。
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
/// 拼授权 URL(`response_type=code` + `prompt=select_account` + 严格编码的重定向 URI)。
|
||||
#[must_use]
|
||||
pub fn authorization_url(
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
redirect_uri: &RedirectUri,
|
||||
state: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}?client_id={}&response_type=code&redirect_uri={}&scope={}&prompt=select_account&state={}",
|
||||
endpoints.authorize,
|
||||
encode(client_id),
|
||||
encode(redirect_uri.as_str()),
|
||||
encode(SCOPE),
|
||||
encode(state),
|
||||
)
|
||||
}
|
||||
|
||||
/// 授权码换 token(`grant_type=authorization_code`)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// OAuth 拒绝([`crate::auth::AuthError::Denied`] /
|
||||
/// [`crate::auth::AuthError::CredentialExpired`])、网络失败、响应缺 token。
|
||||
pub(crate) async fn exchange_code(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
code: &Secret,
|
||||
redirect_uri: &RedirectUri,
|
||||
) -> AuthResult<OAuthTokens> {
|
||||
let reply = post_form(
|
||||
client,
|
||||
&endpoints.token,
|
||||
vec![
|
||||
("client_id", client_id.to_string()),
|
||||
("grant_type", AUTHORIZATION_CODE_GRANT.to_string()),
|
||||
("code", code.expose().to_string()),
|
||||
("redirect_uri", redirect_uri.as_str().to_string()),
|
||||
("scope", SCOPE.to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if reply.text("error").is_some() || !reply.is_success() {
|
||||
return Err(oauth_error(&reply, &endpoints.token));
|
||||
}
|
||||
tokens_from(&reply, &endpoints.token)
|
||||
}
|
||||
|
||||
/// 授权码登录主体:绑端口 → 出授权 URL(交给 `present`)→ 等回调 → 换 token → 整链。
|
||||
///
|
||||
/// `present` 在**端口绑定之后、等待之前**被调用:宿主此时才知道端口,也只有这时
|
||||
/// 打开浏览器才不会让用户对着还没起来的服务端登录。
|
||||
pub(crate) async fn login<F>(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
policy: AuthCodePolicy,
|
||||
present: F,
|
||||
) -> AuthResult<MicrosoftSession>
|
||||
where
|
||||
F: FnOnce(&AuthCodePrompt),
|
||||
{
|
||||
let state = policy.state.clone().unwrap_or_else(nonce);
|
||||
let server = CallbackServer::bind(policy.port, policy.bind_attempts, state.clone()).await?;
|
||||
let redirect_uri = RedirectUri::new(server.port());
|
||||
present(&AuthCodePrompt {
|
||||
authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state),
|
||||
redirect_uri: redirect_uri.as_str().to_string(),
|
||||
timeout: policy.callback_timeout,
|
||||
});
|
||||
let code = server.wait_for_code(policy.callback_timeout).await?;
|
||||
let tokens = exchange_code(
|
||||
client,
|
||||
endpoints,
|
||||
client_id,
|
||||
&Secret::new(code),
|
||||
&redirect_uri,
|
||||
)
|
||||
.await?;
|
||||
complete_chain(client, endpoints, tokens, now_ms()).await
|
||||
}
|
||||
|
||||
/// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。
|
||||
///
|
||||
/// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少
|
||||
/// 128 位熵,挡住「猜到 state 就能伪造回调」这类攻击面。
|
||||
fn nonce() -> String {
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|since| since.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let mut halves = [String::new(), String::new()];
|
||||
for half in &mut halves {
|
||||
// 每次 `RandomState::new()` 都是独立随机种子 → 两段各 64 位、彼此不可推。
|
||||
let mut hasher = RandomState::new().build_hasher();
|
||||
hasher.write_u128(nanos);
|
||||
hasher.write_u32(std::process::id());
|
||||
*half = format!("{:016x}", hasher.finish());
|
||||
}
|
||||
format!("{}{}", halves[0], halves[1])
|
||||
}
|
||||
|
||||
/// 百分号编码(RFC 3986 unreserved 之外一律转义;空格用 `%20` 而不是 `+`)。
|
||||
fn encode(raw: &str) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
for byte in raw.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(char::from(byte));
|
||||
}
|
||||
_ => out.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{authorization_url, encode, RedirectUri, AUTHORIZATION_CODE_GRANT};
|
||||
use crate::auth::microsoft::MicrosoftEndpoints;
|
||||
|
||||
#[test]
|
||||
fn redirect_uri_is_localhost_with_exact_port_and_path() {
|
||||
let uri = RedirectUri::new(51_234);
|
||||
assert_eq!(uri.as_str(), "http://localhost:51234/auth");
|
||||
assert_eq!(uri.port(), 51_234);
|
||||
assert_eq!(uri.to_string(), uri.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_url_matches_research_08_vector() {
|
||||
let endpoints = MicrosoftEndpoints::consumers();
|
||||
let uri = RedirectUri::new(8_080);
|
||||
let url = authorization_url(&endpoints, "0000-client", &uri, "state-123");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize\
|
||||
?client_id=0000-client&response_type=code\
|
||||
&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth\
|
||||
&scope=XboxLive.signin%20offline_access&prompt=select_account&state=state-123"
|
||||
);
|
||||
// 三个硬性细节各断言一遍,改坏了立刻知道是哪一条。
|
||||
assert!(url.contains("/consumers/oauth2/v2.0/authorize"));
|
||||
assert!(url.contains("prompt=select_account"));
|
||||
assert!(url.contains("scope=XboxLive.signin%20offline_access"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_escapes_everything_but_unreserved() {
|
||||
assert_eq!(encode("abc-._~09"), "abc-._~09");
|
||||
assert_eq!(encode("a b/c:"), "a%20b%2Fc%3A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grant_type_is_the_standard_one() {
|
||||
assert_eq!(AUTHORIZATION_CODE_GRANT, "authorization_code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_state_has_128_bits_of_entropy() {
|
||||
let first = super::nonce();
|
||||
let second = super::nonce();
|
||||
assert_eq!(first.len(), 32, "128 位 = 32 个 hex 字符:{first}");
|
||||
assert!(first.chars().all(|c| c.is_ascii_hexdigit()), "{first}");
|
||||
assert_ne!(first, second, "每次登录的 state 必须不同");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! 授权码流的本地回调服务器(`research/08` §3 方案 A:HMCL 主流做法)。
|
||||
//!
|
||||
//! 只干一件事:等浏览器把 `?code=` 送到 `http://localhost:{port}/auth`,把 code 交出去。
|
||||
//! 协议面故意压到最小(只认一个 GET 路径、只回一个静态页),这样它能被单测直接驱动。
|
||||
//!
|
||||
//! # 四条容易踩的细节
|
||||
//!
|
||||
//! 1. **重定向 URI 用 `localhost`,监听绑 `127.0.0.1`**:微软对 `localhost` 与 `127.0.0.1`
|
||||
//! 处理不一致(坑 5),URI 只能写前者;浏览器把 `localhost` 解析到 `::1` 时会被拒一次
|
||||
//! 再回落 IPv4,Chrome/Edge 都走这条回退,所以只绑 v4 就够。
|
||||
//! 2. **端口由内核分配**(绑定 `:0`),端口被占时按 `attempts` 重试;授权 URL 里的
|
||||
//! `redirect_uri` 必须用**最终绑到的端口**回填,换 token 时再原样带上(URI 严格一致)。
|
||||
//! 3. **非 `/auth` 的请求(`/favicon.ico`、浏览器预连)回 404 后继续等**:让一个 favicon
|
||||
//! 把整次登录判成失败是最容易犯的错。
|
||||
//! 4. **`state` 不匹配的请求一律回 400 后继续等**——包括带 `error=` 的那些:旧标签页的
|
||||
//! 迟到回调不该打断新的登录,更要紧的是 RFC 6749 §4.1.2.1 / RFC 9207 要求**错误响应同样
|
||||
//! 要校验 `state`**;否则任何人 `GET /auth?error=access_denied` 就能静默中止别人的登录。
|
||||
//! 真正的超时由调用方的时间预算兜底。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
|
||||
/// 回调路径(重定向 URI 的路径段)。
|
||||
pub const REDIRECT_PATH: &str = "/auth";
|
||||
/// 一次回调请求最多读这么多字节(超了就丢弃连接,防被灌内存)。
|
||||
pub const MAX_REQUEST_HEAD: usize = 8 * 1024;
|
||||
/// 端口被占用时的重试间隔。
|
||||
const RETRY_DELAY: Duration = Duration::from_millis(20);
|
||||
/// 单条连接读完请求头的上限(浏览器/预连不把请求头发完时不能把整个登录卡死)。
|
||||
const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
const SUCCESS_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
|
||||
<title>登录完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
|
||||
<h1>登录完成</h1><p>可以关闭此窗口,回到启动器继续。</p></body></html>";
|
||||
const DENIED_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
|
||||
<title>登录未完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
|
||||
<h1>登录未完成</h1><p>授权没有完成,可以回到启动器重试。</p></body></html>";
|
||||
const BAD_REQUEST_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
|
||||
<title>回调无效</title></head><body style=\"font-family:system-ui;padding:40px\">\
|
||||
<h1>回调无效</h1><p>这个链接不属于本次登录。</p></body></html>";
|
||||
const NOT_FOUND_PAGE: &str = "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
|
||||
<title>Not Found</title></head><body><h1>Not Found</h1></body></html>";
|
||||
|
||||
/// 本地回调服务器(一个实例只服务一次登录)。
|
||||
#[derive(Debug)]
|
||||
pub struct CallbackServer {
|
||||
listener: TcpListener,
|
||||
port: u16,
|
||||
state: String,
|
||||
}
|
||||
|
||||
impl CallbackServer {
|
||||
/// 绑定回调端口。
|
||||
///
|
||||
/// `port` 为 `None` 时由内核分配(生产路径);给定端口时按 `attempts` 重试
|
||||
/// (CLI/测试里固定端口用,端口被占会退避再试)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 全部尝试都绑定失败时返回 [`AuthError::Network`](文案带端口与重试次数)。
|
||||
pub async fn bind(
|
||||
port: Option<u16>,
|
||||
attempts: u8,
|
||||
state: impl Into<String>,
|
||||
) -> AuthResult<Self> {
|
||||
let wanted = port.unwrap_or(0);
|
||||
let attempts = attempts.max(1);
|
||||
let mut last = String::new();
|
||||
for attempt in 0..attempts {
|
||||
match TcpListener::bind(("127.0.0.1", wanted)).await {
|
||||
Ok(listener) => {
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| AuthError::network("localhost", error.to_string()))?
|
||||
.port();
|
||||
return Ok(Self {
|
||||
listener,
|
||||
port,
|
||||
state: state.into(),
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
last = error.to_string();
|
||||
if attempt + 1 < attempts {
|
||||
tokio::time::sleep(RETRY_DELAY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let what = if wanted == 0 {
|
||||
"本地回调端口分配失败".to_string()
|
||||
} else {
|
||||
format!("本地回调端口 {wanted} 绑定失败(重试 {attempts} 次)")
|
||||
};
|
||||
Err(AuthError::network("localhost", format!("{what}:{last}")))
|
||||
}
|
||||
|
||||
/// 实际端口(回填 `redirect_uri` 用)。
|
||||
#[must_use]
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// 本次登录的 `state`(授权 URL 与回调校验共用)。
|
||||
#[must_use]
|
||||
pub fn state(&self) -> &str {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// 等浏览器把 code 送到 `/auth`,最多等 `timeout`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 超时([`AuthError::Timeout`])、用户在授权页点了取消([`AuthError::Canceled`])、
|
||||
/// 授权页回了别的 `error`([`AuthError::Denied`])、socket 出错([`AuthError::Network`])。
|
||||
pub async fn wait_for_code(self, timeout: Duration) -> AuthResult<String> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(wait_timeout(timeout));
|
||||
}
|
||||
let accepted = tokio::time::timeout(remaining, self.listener.accept()).await;
|
||||
let (mut stream, _) = match accepted {
|
||||
Ok(Ok(pair)) => pair,
|
||||
Ok(Err(error)) => {
|
||||
return Err(AuthError::network("localhost", error.to_string()));
|
||||
}
|
||||
Err(_) => return Err(wait_timeout(timeout)),
|
||||
};
|
||||
// 一条连接最多读这么久请求头:慢客户端/半开连接不能把整个登录拖过时间预算。
|
||||
// 预算按 **accept 之后** 的剩余时间重算(用 accept 前那份会让总时长超发最多 5s)。
|
||||
let budget = REQUEST_READ_TIMEOUT
|
||||
.min(deadline.saturating_duration_since(tokio::time::Instant::now()));
|
||||
if budget.is_zero() {
|
||||
return Err(wait_timeout(timeout));
|
||||
}
|
||||
match tokio::time::timeout(budget, self.handle(&mut stream)).await {
|
||||
Ok(result) => {
|
||||
if let Some(code) = result? {
|
||||
return Ok(code);
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理一条连接:`Some(code)` = 登录完成,`None` = 与本次登录无关,继续等。
|
||||
async fn handle(&self, stream: &mut TcpStream) -> AuthResult<Option<String>> {
|
||||
let Some(target) = read_request_target(stream).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (path, query) = match target.split_once('?') {
|
||||
Some((path, query)) => (path, query),
|
||||
None => (target.as_str(), ""),
|
||||
};
|
||||
if path != REDIRECT_PATH {
|
||||
respond(stream, 404, NOT_FOUND_PAGE).await;
|
||||
return Ok(None);
|
||||
}
|
||||
let params = Query::parse(query);
|
||||
// state 必须先校验,且**先于** error:错误响应同样要认 state(RFC 6749 §4.1.2.1 /
|
||||
// RFC 9207)。否则任意一次 `GET /auth?error=access_denied`(state 随便给或不给)就能
|
||||
// 把这台机器上正在进行的登录静默中止 —— 不需要知道随机 state、不需要 code。
|
||||
if params.get("state") != Some(self.state.as_str()) {
|
||||
respond(stream, 400, BAD_REQUEST_PAGE).await;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(error) = params.get("error") {
|
||||
respond(stream, 200, DENIED_PAGE).await;
|
||||
return Err(oauth_error(error, params.get("error_description")));
|
||||
}
|
||||
let Some(code) = params.get("code").filter(|code| !code.is_empty()) else {
|
||||
respond(stream, 400, BAD_REQUEST_PAGE).await;
|
||||
return Ok(None);
|
||||
};
|
||||
respond(stream, 200, SUCCESS_PAGE).await;
|
||||
Ok(Some(code.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_timeout(timeout: Duration) -> AuthError {
|
||||
AuthError::Timeout {
|
||||
what: format!("等待浏览器回调(上限 {} 秒)", timeout.as_secs()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 授权页回的 `error` → 错误分流(`access_denied` 归取消,其余归拒绝)。
|
||||
fn oauth_error(code: &str, description: Option<&str>) -> AuthError {
|
||||
if matches!(code, "access_denied" | "authorization_declined") {
|
||||
return AuthError::Canceled;
|
||||
}
|
||||
AuthError::Denied {
|
||||
code: code.to_string(),
|
||||
description: description.unwrap_or("授权页没有给出原因").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读请求行里的 target(`/auth?code=...`)。非 GET/HEAD、超长头、连接中断都给 `None`。
|
||||
async fn read_request_target(stream: &mut TcpStream) -> Option<String> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
loop {
|
||||
if let Some(end) = find_headers_end(&buffer) {
|
||||
let head = String::from_utf8_lossy(&buffer[..end]).into_owned();
|
||||
let mut parts = head.lines().next()?.split(' ');
|
||||
let method = parts.next()?;
|
||||
if !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD") {
|
||||
return None;
|
||||
}
|
||||
return parts.next().map(str::to_string);
|
||||
}
|
||||
if buffer.len() > MAX_REQUEST_HEAD {
|
||||
return None;
|
||||
}
|
||||
match stream.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => return None,
|
||||
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_headers_end(buffer: &[u8]) -> Option<usize> {
|
||||
buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|position| position + 4)
|
||||
}
|
||||
|
||||
async fn respond(stream: &mut TcpStream, status: u16, page: &str) {
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} {}\r\nContent-Type: text/html; charset=utf-8\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n{page}",
|
||||
reason(status),
|
||||
page.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
|
||||
fn reason(status: u16) -> &'static str {
|
||||
match status {
|
||||
200 => "OK",
|
||||
400 => "Bad Request",
|
||||
404 => "Not Found",
|
||||
_ => "Status",
|
||||
}
|
||||
}
|
||||
|
||||
/// 回调 query 的最小解析(`k=v` 对,percent 解码)。
|
||||
struct Query(Vec<(String, String)>);
|
||||
|
||||
impl Query {
|
||||
fn parse(raw: &str) -> Self {
|
||||
Self(
|
||||
raw.split('&')
|
||||
.filter(|pair| !pair.is_empty())
|
||||
.filter_map(|pair| {
|
||||
let (key, value) = pair.split_once('=')?;
|
||||
Some((decode(key), decode(value)))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Option<&str> {
|
||||
self.0
|
||||
.iter()
|
||||
.find(|(name, _)| name == key)
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// `percent` 解码(`+` 视为空格;坏转义原样保留)。
|
||||
fn decode(raw: &str) -> String {
|
||||
let bytes = raw.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
match bytes[index] {
|
||||
b'%' if index + 2 < bytes.len() => {
|
||||
let parsed = std::str::from_utf8(&bytes[index + 1..index + 3])
|
||||
.ok()
|
||||
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
|
||||
match parsed {
|
||||
Some(byte) => {
|
||||
out.push(byte);
|
||||
index += 3;
|
||||
}
|
||||
None => {
|
||||
out.push(b'%');
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
b'+' => {
|
||||
out.push(b' ');
|
||||
index += 1;
|
||||
}
|
||||
byte => {
|
||||
out.push(byte);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{decode, oauth_error, Query};
|
||||
use crate::auth::error::AuthError;
|
||||
|
||||
#[test]
|
||||
fn query_decodes_percent_and_plus() {
|
||||
let query = Query::parse("code=ab%2Fcd&state=s+1&empty=");
|
||||
assert_eq!(query.get("code"), Some("ab/cd"));
|
||||
assert_eq!(query.get("state"), Some("s 1"));
|
||||
assert_eq!(query.get("empty"), Some(""));
|
||||
assert_eq!(query.get("missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_keeps_broken_escapes() {
|
||||
assert_eq!(decode("100%"), "100%");
|
||||
assert_eq!(decode("%zz"), "%zz");
|
||||
assert_eq!(decode("a%20b"), "a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_denied_maps_to_canceled() {
|
||||
assert_eq!(oauth_error("access_denied", None), AuthError::Canceled);
|
||||
match oauth_error("invalid_request", Some("bad scope")) {
|
||||
AuthError::Denied { code, description } => {
|
||||
assert_eq!(code, "invalid_request");
|
||||
assert_eq!(description, "bad scope");
|
||||
}
|
||||
other => panic!("应是 Denied,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! 微软五步链:Live token → **XBL** → **XSTS** → **login_with_xbox** → **entitlements** → **profile**。
|
||||
//!
|
||||
//! 逐条承 `analyse/04` §5.2(报文级别),四处容易抄错的地方都在注释里点名:
|
||||
//! 1. XBL 的 `RpsTicket` 必须带 **`d=` 前缀**(MSA 消费版票据标记);
|
||||
//! 2. `identityToken` 是 **`XBL3.0 x=<uhs>;<xsts>`**(分号分隔,`x` 后紧跟 `=`);
|
||||
//! 3. XSTS 在「无 Xbox 档案/儿童账号/地区不支持」时回 **401**(不是 200+JSON),
|
||||
//! 必须读 body 里的 `XErr` 才能给出准确原因;
|
||||
//! 4. `/minecraft/profile` 的 **404 二义**:没买游戏 vs 买了没建档案,
|
||||
//! 要用 `entitlements/license` 复判一次才分得清。
|
||||
//!
|
||||
//! **XSTS/MC 令牌不缓存**:刷新就是整链重跑(`analyse/04` §5.1),所以本模块只做无状态串联。
|
||||
|
||||
use reqwest::Client;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::auth::body;
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::auth::secret::{redactor_for, Secret};
|
||||
|
||||
use super::endpoints::MicrosoftEndpoints;
|
||||
use super::flow::OAuthTokens;
|
||||
use super::session::MicrosoftSession;
|
||||
use crate::auth::http::{get_json, post_json, Reply};
|
||||
|
||||
/// Xbox Live 用户票据 + 用户哈希。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct XboxTicket {
|
||||
/// JWT 票据(凭据形状)。
|
||||
pub token: Secret,
|
||||
/// `uhs`(`XBL3.0 x=<uhs>;...` 的 userhash;本身不是凭据但只在本链内部用)。
|
||||
pub uhs: String,
|
||||
}
|
||||
|
||||
/// MC 侧换到的令牌。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct MinecraftToken {
|
||||
/// MC access token(落盘)。
|
||||
pub access_token: Secret,
|
||||
/// 有效期(秒)。
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
/// Minecraft 档案(不可变 id + 可变 name)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct GameProfile {
|
||||
/// 32 位无连字符 UUID。
|
||||
pub id: String,
|
||||
/// 玩家名。
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// 第 1 步:Xbox Live User Authenticate。
|
||||
pub(crate) async fn xbl_authenticate(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
live_access_token: &Secret,
|
||||
) -> Result<XboxTicket, AuthError> {
|
||||
let body = json!({
|
||||
"Properties": {
|
||||
"AuthMethod": "RPS",
|
||||
"SiteName": "user.auth.xboxlive.com",
|
||||
"RpsTicket": format!("d={}", live_access_token.expose()),
|
||||
},
|
||||
"RelyingParty": "http://auth.xboxlive.com",
|
||||
"TokenType": "JWT",
|
||||
});
|
||||
let reply = post_json(client, &endpoints.xbl_authenticate, &body, None).await?;
|
||||
if let Some(code) = xerr(&reply) {
|
||||
return Err(AuthError::Xbox { code });
|
||||
}
|
||||
if !reply.is_success() {
|
||||
// M38:XErr 优先(业务码才是真原因),没有 XErr 时把正文诊断补进文案。
|
||||
return Err(body::response(
|
||||
"xbl",
|
||||
&reply,
|
||||
"Xbox Live 用户认证被拒",
|
||||
&redactor_for([live_access_token]),
|
||||
));
|
||||
}
|
||||
let token = reply
|
||||
.text("Token")
|
||||
.ok_or_else(|| AuthError::Malformed("XBL 响应缺 Token".to_string()))?;
|
||||
let uhs = uhs_of(&reply)
|
||||
.ok_or_else(|| AuthError::Malformed("XBL 响应缺 DisplayClaims.xui[0].uhs".to_string()))?;
|
||||
Ok(XboxTicket {
|
||||
token: Secret::new(token),
|
||||
uhs,
|
||||
})
|
||||
}
|
||||
|
||||
/// 第 2 步:XSTS Authorize(401 也读 body,`XErr` 才是真原因)。
|
||||
pub(crate) async fn xsts_authorize(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
xbl: &XboxTicket,
|
||||
) -> Result<XboxTicket, AuthError> {
|
||||
let body = json!({
|
||||
"Properties": { "SandboxId": "RETAIL", "UserTokens": [xbl.token.expose()] },
|
||||
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||
"TokenType": "JWT",
|
||||
});
|
||||
let reply = post_json(client, &endpoints.xsts_authorize, &body, None).await?;
|
||||
if let Some(code) = xerr(&reply) {
|
||||
return Err(AuthError::Xbox { code });
|
||||
}
|
||||
if !reply.is_success() {
|
||||
return Err(body::response(
|
||||
"xsts",
|
||||
&reply,
|
||||
"XSTS 授权被拒",
|
||||
&redactor_for([&xbl.token]),
|
||||
));
|
||||
}
|
||||
let token = reply
|
||||
.text("Token")
|
||||
.ok_or_else(|| AuthError::Malformed("XSTS 响应缺 Token".to_string()))?;
|
||||
let uhs = uhs_of(&reply).ok_or_else(|| AuthError::Malformed("XSTS 响应缺 uhs".to_string()))?;
|
||||
// 一致性断言:同一账号在 XBL 与 XSTS 两次返回的 userhash 必须相同。
|
||||
if uhs != xbl.uhs {
|
||||
return Err(AuthError::Malformed(
|
||||
"XBL 与 XSTS 返回的 userhash 不一致".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(XboxTicket {
|
||||
token: Secret::new(token),
|
||||
uhs,
|
||||
})
|
||||
}
|
||||
|
||||
/// 第 3 步:`login_with_xbox` 换 Minecraft 令牌。
|
||||
pub(crate) async fn login_with_xbox(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
xsts: &XboxTicket,
|
||||
) -> Result<MinecraftToken, AuthError> {
|
||||
let body = json!({
|
||||
"identityToken": format!("XBL3.0 x={};{}", xsts.uhs, xsts.token.expose()),
|
||||
});
|
||||
let reply = post_json(client, &endpoints.login_with_xbox, &body, None).await?;
|
||||
if !reply.is_success() {
|
||||
// M38 的核心一行:403 到底是「拦截页」还是「服务端拒绝」,文案要分开引导。
|
||||
return Err(body::response(
|
||||
"login_with_xbox",
|
||||
&reply,
|
||||
"Minecraft 登录被拒",
|
||||
&redactor_for([&xsts.token]),
|
||||
));
|
||||
}
|
||||
let access_token = reply
|
||||
.text("access_token")
|
||||
.ok_or_else(|| AuthError::Malformed("login_with_xbox 缺 access_token".to_string()))?;
|
||||
Ok(MinecraftToken {
|
||||
access_token: Secret::new(access_token),
|
||||
expires_in: reply.number("expires_in").unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// 第 4 步:拥有权校验(必须 200,否则后面启动时也会被服务端踢)。
|
||||
pub(crate) async fn check_entitlements(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
mc_token: &Secret,
|
||||
) -> Result<(), AuthError> {
|
||||
let reply = get_json(client, &endpoints.entitlements_mcstore, mc_token).await?;
|
||||
if reply.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
let detail = body::rejection(
|
||||
"entitlements",
|
||||
&reply,
|
||||
"拥有权校验未通过",
|
||||
&redactor_for([mc_token]),
|
||||
);
|
||||
// 拦截页(HTML/纯文本)与非 404 的空体先当网络问题报,别让用户去查许可;
|
||||
// JSON 拒绝与 404 沿用 M31 的 `no_entitlement`(稳定码不改)。
|
||||
let interception = detail.kind.is_interception()
|
||||
|| (detail.kind == body::BodyKind::Empty && reply.status != 404);
|
||||
if interception {
|
||||
return Err(AuthError::Response(Box::new(detail)));
|
||||
}
|
||||
let what = if reply.status == 404 {
|
||||
"该账号没有 Minecraft Java 版拥有权"
|
||||
} else {
|
||||
detail.what.as_str()
|
||||
};
|
||||
Err(AuthError::Denied {
|
||||
code: "no_entitlement".to_string(),
|
||||
description: format!(
|
||||
"{what}(entitlements/mcstore 返回 HTTP {}){}",
|
||||
reply.status,
|
||||
detail.note()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// 第 5 步:拉档案(404 用 license 复判二义)。
|
||||
pub(crate) async fn fetch_profile(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
mc_token: &Secret,
|
||||
) -> Result<GameProfile, AuthError> {
|
||||
let reply = get_json(client, &endpoints.profile, mc_token).await?;
|
||||
// 只有「像服务端应答」的 404 才走二义复判:HTML/纯文本的 404 是网关页,
|
||||
// 拿它去复判 license 会得出错误的「没买游戏」结论。
|
||||
if reply.status == 404 && !body::classify(&reply).is_interception() {
|
||||
return Err(profile_missing(client, endpoints, mc_token).await);
|
||||
}
|
||||
if !reply.is_success() {
|
||||
return Err(body::response(
|
||||
"profile",
|
||||
&reply,
|
||||
"拉取 Minecraft 档案被拒",
|
||||
&redactor_for([mc_token]),
|
||||
));
|
||||
}
|
||||
let id = reply
|
||||
.text("id")
|
||||
.ok_or_else(|| AuthError::Malformed("档案响应缺 id".to_string()))?;
|
||||
let name = reply
|
||||
.text("name")
|
||||
.ok_or_else(|| AuthError::Malformed("档案响应缺 name".to_string()))?;
|
||||
Ok(GameProfile {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 404 之后的二义消解:没买游戏 vs 买了没建档案。
|
||||
async fn profile_missing(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
mc_token: &Secret,
|
||||
) -> AuthError {
|
||||
let reply = match get_json(client, &endpoints.entitlements_license, mc_token).await {
|
||||
Ok(reply) => reply,
|
||||
// 复判本身失败时,退回「档案不存在」这个更保守的说法。
|
||||
Err(_) => {
|
||||
return AuthError::Denied {
|
||||
code: "no_profile".to_string(),
|
||||
description: "该账号没有 Minecraft 档案(复判 license 也失败)".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
if has_minecraft_license(&reply) {
|
||||
AuthError::Denied {
|
||||
code: "no_profile".to_string(),
|
||||
description: "账号已拥有 Minecraft,但还没有创建档案(去 minecraft.net 起个名字)"
|
||||
.to_string(),
|
||||
}
|
||||
} else {
|
||||
AuthError::Denied {
|
||||
code: "no_license".to_string(),
|
||||
description: "该账号没有购买 Minecraft Java 版".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `entitlements/license` 响应里是否有 `game_minecraft`。
|
||||
fn has_minecraft_license(reply: &Reply) -> bool {
|
||||
reply
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("items"))
|
||||
.and_then(|items| items.as_array())
|
||||
.is_some_and(|items| {
|
||||
items.iter().any(|item| {
|
||||
item.get("name")
|
||||
.and_then(|name| name.as_str())
|
||||
.is_some_and(|name| name == "game_minecraft")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 整链:Live token → [`MicrosoftSession`](刷新与新登录共用这一段)。
|
||||
pub(crate) async fn complete_chain(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
tokens: OAuthTokens,
|
||||
now_ms: u64,
|
||||
) -> Result<MicrosoftSession, AuthError> {
|
||||
let xbl = xbl_authenticate(client, endpoints, &tokens.access_token).await?;
|
||||
let xsts = xsts_authorize(client, endpoints, &xbl).await?;
|
||||
let mc = login_with_xbox(client, endpoints, &xsts).await?;
|
||||
check_entitlements(client, endpoints, &mc.access_token).await?;
|
||||
let profile = fetch_profile(client, endpoints, &mc.access_token).await?;
|
||||
Ok(MicrosoftSession::new(
|
||||
profile.id,
|
||||
profile.name,
|
||||
mc.access_token,
|
||||
tokens.refresh_token,
|
||||
mc.expires_in,
|
||||
now_ms,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
/// 从响应里取 `XErr`(XSTS 的 401 业务码)。
|
||||
fn xerr(reply: &Reply) -> Option<u32> {
|
||||
let body = reply.body.as_ref()?;
|
||||
for key in ["XErr", "errorCode"] {
|
||||
if let Some(code) = body.get(key).and_then(|value| value.as_u64()) {
|
||||
if code != 0 {
|
||||
return Some(code as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn uhs_of(reply: &Reply) -> Option<String> {
|
||||
reply
|
||||
.body
|
||||
.as_ref()?
|
||||
.get("DisplayClaims")?
|
||||
.get("xui")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("uhs")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{has_minecraft_license, uhs_of, xerr, Reply};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn reply(status: u16, body: Option<Value>) -> Reply {
|
||||
Reply {
|
||||
status,
|
||||
body,
|
||||
content_type: Some("application/json".to_string()),
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xerr_is_read_from_401_body() {
|
||||
let sample = reply(
|
||||
401,
|
||||
Some(json!({ "Identity": "0", "XErr": 2_148_916_238_u32, "Message": "" })),
|
||||
);
|
||||
assert_eq!(xerr(&sample), Some(2_148_916_238));
|
||||
assert_eq!(xerr(&reply(200, Some(json!({ "XErr": 0 })))), None);
|
||||
assert_eq!(xerr(&reply(200, None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uhs_comes_from_first_xui_entry() {
|
||||
let sample = reply(
|
||||
200,
|
||||
Some(json!({ "DisplayClaims": { "xui": [{ "uhs": "1234" }] } })),
|
||||
);
|
||||
assert_eq!(uhs_of(&sample).as_deref(), Some("1234"));
|
||||
assert_eq!(
|
||||
uhs_of(&reply(200, Some(json!({ "DisplayClaims": {} })))),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_detection() {
|
||||
let with = reply(
|
||||
200,
|
||||
Some(
|
||||
json!({ "items": [{ "name": "product_minecraft" }, { "name": "game_minecraft" }] }),
|
||||
),
|
||||
);
|
||||
let without = reply(
|
||||
200,
|
||||
Some(json!({ "items": [{ "name": "product_minecraft" }] })),
|
||||
);
|
||||
assert!(has_minecraft_license(&with));
|
||||
assert!(!has_minecraft_license(&without));
|
||||
assert!(!has_minecraft_license(&reply(500, None)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Azure 公共客户端 id 的**配置状态**:从哪来、配没配、启动日志那一行长什么样。
|
||||
//!
|
||||
//! ## 为什么单独一块(M117)
|
||||
//!
|
||||
//! 这是**公共客户端 id**(非机密,随二进制一起分发),可以来自两处:运行期环境变量 [`CLIENT_ID_ENV`],
|
||||
//! 或构建期 `option_env!` 注入([`CLIENT_ID_BUILD`])——官方发布构建在构建时烘入它(M149),
|
||||
//! 所以官方产物开箱即用;环境变量仍优先,可覆盖。**两处都没有**时登录是「没配置」,
|
||||
//! 不是「没实现」——这两件事在用户眼里完全不同,文案与日志都必须分清:
|
||||
//!
|
||||
//! | 现象 | 该说什么 |
|
||||
//! |---|---|
|
||||
//! | 两处都没配 | [`MISSING_CLIENT_ID_MESSAGE`]:未配置 + 怎么配(含可照抄的命令) |
|
||||
//! | 配了但登录失败 | 走 `AuthError` 的正常分流(网络/拒绝/无拥有权…),**不许**再提「未实现」 |
|
||||
//!
|
||||
//! [`ClientIdStatus::log_line`] 是给启动日志的那一行:`[auth] msa client_id=… source=…`。
|
||||
//! 有了它,「未配置」与「配了但登录失败」在日志里一眼可分,不用再猜。
|
||||
//!
|
||||
//! 本模块**零网络**:只读环境变量与编译期常量,连 HTTP 客户端都不建。
|
||||
|
||||
/// 运行期环境变量名(配 client id)。
|
||||
pub const CLIENT_ID_ENV: &str = "ULL_MSA_CLIENT_ID";
|
||||
|
||||
/// 构建期可注入的 client id(`ULL_MSA_CLIENT_ID=<id> cargo build`)。
|
||||
///
|
||||
/// 官方发布构建就是这么烘的(`.github/workflows/release-build.yml` 给构建步设了同名环境变量,M149;
|
||||
/// 值来自仓库 Secret,不进 git 文件),所以官方产物走到这里恒为 `Some`;从源码自建时才可能为空。
|
||||
pub const CLIENT_ID_BUILD: Option<&str> = option_env!("ULL_MSA_CLIENT_ID");
|
||||
|
||||
/// 两处都没配时给用户看的那句话(CLI / GUI / 启动日志共用,**不含任何真实 id**)。
|
||||
///
|
||||
/// 措辞纪律(M117):说「未配置」,不说「未支持」——登录链路是完整的,缺的只是这一项;
|
||||
/// 并且句子里带**可照抄的命令**,因为失败横幅往往是用户唯一会看到的那段字。
|
||||
pub const MISSING_CLIENT_ID_MESSAGE: &str =
|
||||
"微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。\
|
||||
构建时注入:`ULL_MSA_CLIENT_ID=<id> cargo build`;或启动前设置同名环境变量 `ULL_MSA_CLIENT_ID=<id>`。";
|
||||
|
||||
/// client id 从哪来(启动日志与排障用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientIdSource {
|
||||
/// 运行期环境变量 `ULL_MSA_CLIENT_ID`(**优先于**构建期注入)。
|
||||
Env,
|
||||
/// 构建期 `option_env!` 注入。
|
||||
Build,
|
||||
}
|
||||
|
||||
impl ClientIdSource {
|
||||
/// 启动日志里的机读标签(`env` / `build`)。
|
||||
#[must_use]
|
||||
pub const fn tag(self) -> &'static str {
|
||||
match self {
|
||||
Self::Env => "env",
|
||||
Self::Build => "build",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// client id 的配置状态:配没配 + 从哪来(`None` = 两处都没有)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ClientIdStatus {
|
||||
source: Option<ClientIdSource>,
|
||||
}
|
||||
|
||||
impl ClientIdStatus {
|
||||
/// 拿到 id 了没(`false` = 需要配置,不是功能缺失)。
|
||||
#[must_use]
|
||||
pub const fn is_configured(self) -> bool {
|
||||
self.source.is_some()
|
||||
}
|
||||
|
||||
/// 来源(`None` = 两处都没有)。
|
||||
#[must_use]
|
||||
pub const fn source(self) -> Option<ClientIdSource> {
|
||||
self.source
|
||||
}
|
||||
|
||||
/// 启动日志那一行:`[auth] msa client_id=configured|missing source=env|build|none`。
|
||||
///
|
||||
/// 判据(M117):日后看日志能直接分辨「未配置」与「配置了但登录失败」。
|
||||
#[must_use]
|
||||
pub fn log_line(self) -> String {
|
||||
format!(
|
||||
"[auth] msa client_id={} source={}",
|
||||
if self.source.is_some() {
|
||||
"configured"
|
||||
} else {
|
||||
"missing"
|
||||
},
|
||||
self.source.map_or("none", ClientIdSource::tag)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前进程看到的 client id 与来源(**只读环境/编译期常量,不联网**)。
|
||||
///
|
||||
/// 优先级:环境变量 > 构建期注入。两者都存在时**不告警**(M117):环境变量就是显式覆盖,
|
||||
/// 来源如实报 [`ClientIdSource::Env`],日志里看得到。
|
||||
#[must_use]
|
||||
pub fn resolve_client_id() -> Option<(String, ClientIdSource)> {
|
||||
let from_env = std::env::var(CLIENT_ID_ENV)
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let from_build = CLIENT_ID_BUILD.filter(|value| !value.trim().is_empty());
|
||||
match (from_env, from_build) {
|
||||
(Some(value), _) => Some((value, ClientIdSource::Env)),
|
||||
(None, Some(value)) => Some((value.to_string(), ClientIdSource::Build)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 只问「配没配 / 从哪来」——启动日志与 GUI 启动行用它(不建 HTTP 客户端、不联网)。
|
||||
#[must_use]
|
||||
pub fn client_id_status() -> ClientIdStatus {
|
||||
ClientIdStatus {
|
||||
source: resolve_client_id().map(|(_, source)| source),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::{
|
||||
client_id_status, resolve_client_id, ClientIdSource, CLIENT_ID_BUILD, CLIENT_ID_ENV,
|
||||
MISSING_CLIENT_ID_MESSAGE,
|
||||
};
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::auth::microsoft::MicrosoftAuth;
|
||||
|
||||
/// 环境变量是**进程级**的:动它的用例必须串行,否则并行跑会互相摘/设,结论随机。
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// 用例结束后把环境变量还原(panic 也还原:`Drop` 跑得到)。
|
||||
struct EnvGuard {
|
||||
saved: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn clear() -> Self {
|
||||
let saved = std::env::var(CLIENT_ID_ENV).ok();
|
||||
std::env::remove_var(CLIENT_ID_ENV);
|
||||
Self { saved }
|
||||
}
|
||||
|
||||
fn set(value: &str) -> Self {
|
||||
let saved = std::env::var(CLIENT_ID_ENV).ok();
|
||||
std::env::set_var(CLIENT_ID_ENV, value);
|
||||
Self { saved }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.saved.take() {
|
||||
Some(value) => std::env::set_var(CLIENT_ID_ENV, value),
|
||||
None => std::env::remove_var(CLIENT_ID_ENV),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_client_id_message_says_unconfigured_not_unimplemented() {
|
||||
// 抓得住:文案退回「本期未支持」这类说法(用户实跑到的那个 bug),
|
||||
// 或者只说「需要 id」却不给怎么配(用户下一步就卡在这里)。
|
||||
// 抓不住:文案好不好读——那要靠 review。
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("客户端 id"));
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("未配置"));
|
||||
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未支持"));
|
||||
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未实现"));
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains(CLIENT_ID_ENV));
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("cargo build"));
|
||||
// 硬约束:这句话进日志、进横幅、进错误,**示例一律是 `<id>` 占位**,不出现真 id。
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("<id>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_without_client_id_is_a_config_error_not_a_feature_gap() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let _guard = EnvGuard::clear();
|
||||
let result = MicrosoftAuth::from_env();
|
||||
match result {
|
||||
Err(AuthError::Unsupported(message)) => {
|
||||
// 机读码保持 `unsupported`(CLI `--json` 与既有调用方靠它分流),
|
||||
// 但**人读文案**必须说「未配置」。
|
||||
assert_eq!(message, MISSING_CLIENT_ID_MESSAGE);
|
||||
assert!(!message.contains("未支持"));
|
||||
}
|
||||
// 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里;
|
||||
// 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。
|
||||
// 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红**
|
||||
// (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。
|
||||
Ok(_) => assert!(
|
||||
CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()),
|
||||
"没配 id 却构造成功"
|
||||
),
|
||||
Err(other) => panic!("应是 Unsupported,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_client_id_wins_over_build_and_the_log_says_so() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let _guard = EnvGuard::set("0000-test-env-id");
|
||||
|
||||
let (id, source) = resolve_client_id().expect("环境变量给了 id");
|
||||
assert_eq!(id, "0000-test-env-id");
|
||||
// 两者都存在时环境变量优先,且**不告警**,但来源必须是 env。
|
||||
assert_eq!(source, ClientIdSource::Env);
|
||||
|
||||
let status = client_id_status();
|
||||
assert!(status.is_configured());
|
||||
assert_eq!(status.source(), Some(ClientIdSource::Env));
|
||||
assert_eq!(
|
||||
status.log_line(),
|
||||
"[auth] msa client_id=configured source=env"
|
||||
);
|
||||
|
||||
// 有 id 时**不再报配置错误**:只验到「进得了登录流程的入口」为止,
|
||||
// `from_env` 本身零网络(它只建 reqwest 客户端,不发请求)。
|
||||
let auth = MicrosoftAuth::from_env().expect("配了 id 就该构造成功");
|
||||
assert!(auth.endpoints().profile.contains("minecraftservices.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_client_id_log_line_is_machine_readable() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let _guard = EnvGuard::clear();
|
||||
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
|
||||
// 构建期注入过 id:这条没有「缺配置」可言,跳过。
|
||||
return;
|
||||
}
|
||||
let status = client_id_status();
|
||||
assert!(!status.is_configured());
|
||||
assert_eq!(status.source(), None);
|
||||
assert_eq!(
|
||||
status.log_line(),
|
||||
"[auth] msa client_id=missing source=none"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_env_value_counts_as_missing() {
|
||||
// 抓得住:`ULL_MSA_CLIENT_ID=""`(空串/全空白)被当成配好了,进而拿空 id 去联网。
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let _guard = EnvGuard::set(" ");
|
||||
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
|
||||
assert_eq!(
|
||||
resolve_client_id().map(|(_, source)| source),
|
||||
Some(ClientIdSource::Build),
|
||||
"空白环境变量必须让位给构建期注入"
|
||||
);
|
||||
} else {
|
||||
assert!(resolve_client_id().is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。
|
||||
//!
|
||||
//! 两条流**共用同一个端点族**(`research/08` §2 硬性约束 1:租户只能用 `consumers`):
|
||||
//! - 设备码流:`devicecode` + `token`;
|
||||
//! - 授权码流:`authorize`(浏览器打开)+ 同一个 `token`(`grant_type=authorization_code`)。
|
||||
//!
|
||||
//! 都走 `login.microsoftonline.com/consumers/oauth2/v2.0/*`:用 `common` 或 AAD 租户 id
|
||||
//! 拿 `XboxLive.signin` 会直接被拒(`analyse/04` §4 的「第一个坑」)。
|
||||
//!
|
||||
//! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链,
|
||||
//! 生产默认走 [`MicrosoftEndpoints::consumers`]。
|
||||
|
||||
/// 微软账户 OAuth 与 Xbox / Minecraft 服务端点。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MicrosoftEndpoints {
|
||||
/// 设备码申请(`POST`,表单 `client_id` + `scope`)。
|
||||
pub device_code: String,
|
||||
/// 授权码流的授权页(`GET`,浏览器打开;`research/08` §3 方案 A)。
|
||||
pub authorize: String,
|
||||
/// 换 token / 刷新 token / 授权码换 token(`POST`,表单)。
|
||||
pub token: String,
|
||||
/// Xbox Live 用户认证(`POST` JSON)。
|
||||
pub xbl_authenticate: String,
|
||||
/// XSTS 授权(`POST` JSON;无 Xbox 档案时回 **401** 带 `XErr`)。
|
||||
pub xsts_authorize: String,
|
||||
/// `login_with_xbox`(`POST` JSON,`identityToken = "XBL3.0 x=<uhs>;<xsts>"`)。
|
||||
pub login_with_xbox: String,
|
||||
/// 拥有权校验 `entitlements/mcstore`(`GET`,必须 200)。
|
||||
pub entitlements_mcstore: String,
|
||||
/// 拥有权细分 `entitlements/license`(`GET`,`/minecraft/profile` 404 时复判用)。
|
||||
pub entitlements_license: String,
|
||||
/// 档案 `minecraft/profile`(`GET`,带 Bearer)。
|
||||
pub profile: String,
|
||||
}
|
||||
|
||||
impl MicrosoftEndpoints {
|
||||
/// 生产端点(微软消费版账户,直连)。
|
||||
#[must_use]
|
||||
pub fn consumers() -> Self {
|
||||
Self {
|
||||
device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
|
||||
.to_string(),
|
||||
authorize: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"
|
||||
.to_string(),
|
||||
token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(),
|
||||
xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(),
|
||||
xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".to_string(),
|
||||
login_with_xbox: "https://api.minecraftservices.com/authentication/login_with_xbox"
|
||||
.to_string(),
|
||||
entitlements_mcstore: "https://api.minecraftservices.com/entitlements/mcstore"
|
||||
.to_string(),
|
||||
entitlements_license: "https://api.minecraftservices.com/entitlements/license"
|
||||
.to_string(),
|
||||
profile: "https://api.minecraftservices.com/minecraft/profile".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 把整表指向同一个基址(测试用;各端点用自己的路径后缀区分)。
|
||||
#[must_use]
|
||||
pub fn all_on(base: &str) -> Self {
|
||||
let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/'));
|
||||
Self {
|
||||
device_code: join("devicecode"),
|
||||
authorize: join("authorize"),
|
||||
token: join("token"),
|
||||
xbl_authenticate: join("xbl"),
|
||||
xsts_authorize: join("xsts"),
|
||||
login_with_xbox: join("login_with_xbox"),
|
||||
entitlements_mcstore: join("entitlements/mcstore"),
|
||||
entitlements_license: join("entitlements/license"),
|
||||
profile: join("profile"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 所有端点(调试/诊断用)。
|
||||
#[must_use]
|
||||
pub fn all(&self) -> [&str; 9] {
|
||||
[
|
||||
&self.device_code,
|
||||
&self.authorize,
|
||||
&self.token,
|
||||
&self.xbl_authenticate,
|
||||
&self.xsts_authorize,
|
||||
&self.login_with_xbox,
|
||||
&self.entitlements_mcstore,
|
||||
&self.entitlements_license,
|
||||
&self.profile,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MicrosoftEndpoints {
|
||||
fn default() -> Self {
|
||||
Self::consumers()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MicrosoftEndpoints;
|
||||
|
||||
#[test]
|
||||
fn consumers_endpoints_stay_on_two_families() {
|
||||
let endpoints = MicrosoftEndpoints::consumers();
|
||||
assert!(endpoints.device_code.contains("login.microsoftonline.com"));
|
||||
assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token"));
|
||||
// 授权码流的授权页与设备码流同族同租户(`research/08` §2:只能用 consumers)。
|
||||
assert!(endpoints
|
||||
.authorize
|
||||
.contains("/consumers/oauth2/v2.0/authorize"));
|
||||
assert!(endpoints
|
||||
.xbl_authenticate
|
||||
.contains("user.auth.xboxlive.com"));
|
||||
assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com"));
|
||||
// 微软 OAuth 两流都不走 HMCL 的 login.live.com 端点族。
|
||||
assert!(endpoints
|
||||
.all()
|
||||
.iter()
|
||||
.all(|url| !url.contains("login.live.com")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_on_keeps_paths_distinct() {
|
||||
let endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1234/");
|
||||
assert_eq!(endpoints.device_code, "http://127.0.0.1:1234/devicecode");
|
||||
assert_eq!(endpoints.authorize, "http://127.0.0.1:1234/authorize");
|
||||
assert_eq!(
|
||||
endpoints.entitlements_license,
|
||||
"http://127.0.0.1:1234/entitlements/license"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! device-code 流:发起(拿 `user_code` + `verification_uri`)→ 轮询换 token →(刷新同构)。
|
||||
//!
|
||||
//! 逐条承 `analyse/04` §4.3 / §4.4 / §4.5:
|
||||
//! - **硬超时**:只等 `min(expires_in, 900s)`,防止后台任务永久挂起;
|
||||
//! - **`slow_down` 是流程内状态**,不是失败:轮询间隔 +5 秒继续;
|
||||
//! - **每次轮询前先 sleep**(别密集打服务端);
|
||||
//! - **refresh 同时返回新 refresh_token**,调用方必须整体覆盖(轮换);
|
||||
//! - 收紧 HMCL 的观察项 1:**只要 `error` 非空就失败**,不再「描述为空就静默放行」。
|
||||
//!
|
||||
//! 打印 `user_code`/URL 不在这里:core 不做 UI,宿主拿 [`DeviceCodePrompt`] 自己决定
|
||||
//! (CLI 打到 stderr,GUI 弹对话框 + 二维码)。
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
use super::endpoints::MicrosoftEndpoints;
|
||||
use crate::auth::http::{post_form, Reply};
|
||||
|
||||
/// 申请的 scope(`XboxLive.signin` + 长期刷新)。
|
||||
pub const SCOPE: &str = "XboxLive.signin offline_access";
|
||||
/// 设备码流程的硬上限:无论服务端给多长,最多等这么久。
|
||||
pub const DEVICE_CODE_MAX_WAIT: Duration = Duration::from_secs(900);
|
||||
/// 默认轮询间隔下限(服务端 `interval` 更小时按下限走)。
|
||||
pub const DEFAULT_MIN_INTERVAL: Duration = Duration::from_secs(1);
|
||||
/// `slow_down` 的退避步长(承 HMCL:+5 秒)。
|
||||
pub const DEFAULT_SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
|
||||
/// device_code grant 类型。
|
||||
pub const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
/// 轮询策略(生产用 [`PollPolicy::default`],测试用小间隔跑同一套状态机)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PollPolicy {
|
||||
/// 轮询间隔下限。
|
||||
pub min_interval: Duration,
|
||||
/// `slow_down` 的退避步长。
|
||||
pub slow_down_step: Duration,
|
||||
/// 整个流程最多等多久(还会与 `expires_in` 取小)。
|
||||
pub max_wait: Duration,
|
||||
}
|
||||
|
||||
impl Default for PollPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_interval: DEFAULT_MIN_INTERVAL,
|
||||
slow_down_step: DEFAULT_SLOW_DOWN_STEP,
|
||||
max_wait: DEVICE_CODE_MAX_WAIT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 要展示给用户的信息(URL + 用户码)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeviceCodePrompt {
|
||||
/// 用户在验证页输入的短码(如 `ABCD1234`)。**不是凭据**,可打印。
|
||||
pub user_code: String,
|
||||
/// 验证地址(`https://www.microsoft.com/link` 等)。
|
||||
pub verification_uri: String,
|
||||
/// 服务端给的有效期。
|
||||
pub expires_in: Duration,
|
||||
/// 服务端建议的轮询间隔。
|
||||
pub interval: Duration,
|
||||
}
|
||||
|
||||
impl DeviceCodePrompt {
|
||||
/// 人读两行(CLI 打到 stderr,GUI 直接展示)。
|
||||
#[must_use]
|
||||
pub fn lines(&self) -> (String, String) {
|
||||
(
|
||||
format!("请在任意设备打开 {} 并输入代码:", self.verification_uri),
|
||||
format!(
|
||||
"用户码 {}({} 分钟内有效)",
|
||||
self.user_code,
|
||||
self.expires_in.as_secs() / 60
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备码申请的中间态(`device_code` 是凭据形状,不进日志)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DeviceCodeGrant {
|
||||
/// 展示信息。
|
||||
pub prompt: DeviceCodePrompt,
|
||||
/// 轮询用的长码(脱敏 Debug,永不打印)。
|
||||
pub device_code: Secret,
|
||||
}
|
||||
|
||||
/// OAuth 换到的 token 对。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct OAuthTokens {
|
||||
/// Live access token(只在链内用,不落盘)。
|
||||
pub access_token: Secret,
|
||||
/// Live refresh token(落盘、轮换)。
|
||||
pub refresh_token: Secret,
|
||||
}
|
||||
|
||||
/// 申请设备码。
|
||||
pub(crate) async fn request_device_code(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
) -> AuthResult<DeviceCodeGrant> {
|
||||
let reply = post_form(
|
||||
client,
|
||||
&endpoints.device_code,
|
||||
vec![
|
||||
("client_id", client_id.to_string()),
|
||||
("scope", SCOPE.to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if !reply.is_success() {
|
||||
return Err(oauth_error(&reply, &endpoints.device_code));
|
||||
}
|
||||
let user_code = reply
|
||||
.text("user_code")
|
||||
.ok_or_else(|| AuthError::Malformed("设备码响应缺 user_code".to_string()))?;
|
||||
let device_code = reply
|
||||
.text("device_code")
|
||||
.ok_or_else(|| AuthError::Malformed("设备码响应缺 device_code".to_string()))?;
|
||||
let verification_uri = reply
|
||||
.text("verification_uri")
|
||||
.or_else(|| reply.text("verification_url"))
|
||||
.ok_or_else(|| AuthError::Malformed("设备码响应缺 verification_uri".to_string()))?;
|
||||
let expires_in = reply.number("expires_in").unwrap_or(900).max(1);
|
||||
// 服务端间隔原样收下,下限交给 [`PollPolicy::min_interval`](生产 1 秒,测试可压到毫秒)。
|
||||
let interval = reply.number("interval").unwrap_or(5);
|
||||
Ok(DeviceCodeGrant {
|
||||
prompt: DeviceCodePrompt {
|
||||
user_code: user_code.to_string(),
|
||||
verification_uri: verification_uri.to_string(),
|
||||
expires_in: Duration::from_secs(expires_in),
|
||||
interval: Duration::from_secs(interval),
|
||||
},
|
||||
device_code: Secret::new(device_code),
|
||||
})
|
||||
}
|
||||
|
||||
/// 轮询直到用户完成授权(或超时/被拒/取消)。
|
||||
pub(crate) async fn poll_device_token(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
grant: &DeviceCodeGrant,
|
||||
policy: PollPolicy,
|
||||
) -> AuthResult<OAuthTokens> {
|
||||
let limit = grant.prompt.expires_in.min(policy.max_wait);
|
||||
let mut interval = grant.prompt.interval.max(policy.min_interval);
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
if started.elapsed() >= limit {
|
||||
return Err(AuthError::Timeout {
|
||||
what: format!("等待设备码授权(上限 {} 秒)", limit.as_secs()),
|
||||
});
|
||||
}
|
||||
tokio::time::sleep(interval).await;
|
||||
let reply = post_form(
|
||||
client,
|
||||
&endpoints.token,
|
||||
vec![
|
||||
("client_id", client_id.to_string()),
|
||||
("grant_type", DEVICE_CODE_GRANT.to_string()),
|
||||
("code", grant.device_code.expose().to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(error) = reply.text("error") {
|
||||
match error {
|
||||
"authorization_pending" => continue,
|
||||
// 服务端压力反馈:不是失败,退避后继续(承 HMCL)。
|
||||
"slow_down" => {
|
||||
interval += policy.slow_down_step;
|
||||
continue;
|
||||
}
|
||||
"expired_token" => {
|
||||
return Err(AuthError::Timeout {
|
||||
what: "设备码已过期(需重新发起登录)".to_string(),
|
||||
})
|
||||
}
|
||||
"access_denied" | "authorization_declined" => return Err(AuthError::Canceled),
|
||||
_ => return Err(oauth_error(&reply, &endpoints.token)),
|
||||
}
|
||||
}
|
||||
return tokens_from(&reply, &endpoints.token);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 refresh_token 换新的 token 对(**刷新也会返回新 refresh_token**)。
|
||||
pub(crate) async fn refresh_tokens(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
refresh_token: &Secret,
|
||||
) -> AuthResult<OAuthTokens> {
|
||||
let reply = post_form(
|
||||
client,
|
||||
&endpoints.token,
|
||||
vec![
|
||||
("client_id", client_id.to_string()),
|
||||
("grant_type", "refresh_token".to_string()),
|
||||
("refresh_token", refresh_token.expose().to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if let Some(error) = reply.text("error") {
|
||||
// `invalid_grant` + `AADSTS70000` = 授权被撤销/过期 → 唯一该重新登录的信号。
|
||||
if error == "invalid_grant" {
|
||||
let description = reply.text("error_description").unwrap_or("");
|
||||
if description.contains("AADSTS70000") {
|
||||
return Err(AuthError::CredentialExpired);
|
||||
}
|
||||
}
|
||||
return Err(oauth_error(&reply, &endpoints.token));
|
||||
}
|
||||
if !reply.is_success() {
|
||||
return Err(oauth_error(&reply, &endpoints.token));
|
||||
}
|
||||
tokens_from(&reply, &endpoints.token)
|
||||
}
|
||||
|
||||
pub(crate) fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
|
||||
let access_token = reply
|
||||
.text("access_token")
|
||||
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 access_token")))?;
|
||||
let refresh_token = reply
|
||||
.text("refresh_token")
|
||||
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 refresh_token")))?;
|
||||
Ok(OAuthTokens {
|
||||
access_token: Secret::new(access_token),
|
||||
refresh_token: Secret::new(refresh_token),
|
||||
})
|
||||
}
|
||||
|
||||
/// OAuth 错误归一:**`error` 非空即失败**(HMCL 只在两个字段都非空时处理,我们收紧)。
|
||||
pub(crate) fn oauth_error(reply: &Reply, url: &str) -> AuthError {
|
||||
let Some(code) = reply.text("error") else {
|
||||
return AuthError::network(
|
||||
url.split('?').next().unwrap_or(url),
|
||||
format!("HTTP {}(响应不是 OAuth 错误体)", reply.status),
|
||||
);
|
||||
};
|
||||
let description = reply
|
||||
.text("error_description")
|
||||
.unwrap_or("服务端未给描述")
|
||||
.to_string();
|
||||
if code == "invalid_grant" && description.contains("AADSTS70000") {
|
||||
return AuthError::CredentialExpired;
|
||||
}
|
||||
AuthError::Denied {
|
||||
code: code.to_string(),
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{oauth_error, PollPolicy, DEFAULT_MIN_INTERVAL, DEVICE_CODE_MAX_WAIT};
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::auth::http::Reply;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn default_policy_matches_hmcl_bounds() {
|
||||
let policy = PollPolicy::default();
|
||||
assert_eq!(policy.min_interval, DEFAULT_MIN_INTERVAL);
|
||||
assert_eq!(policy.max_wait, DEVICE_CODE_MAX_WAIT);
|
||||
assert_eq!(policy.max_wait.as_secs(), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_without_description_still_fails() {
|
||||
let reply = reply(400, Some(json!({ "error": "invalid_request" })));
|
||||
match oauth_error(&reply, "https://example.invalid/token") {
|
||||
AuthError::Denied { code, .. } => assert_eq!(code, "invalid_request"),
|
||||
other => panic!("应是 Denied,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aadsts70000_becomes_credential_expired() {
|
||||
let reply = reply(
|
||||
400,
|
||||
Some(json!({
|
||||
"error": "invalid_grant",
|
||||
"error_description": "AADSTS70000: The grant is expired or revoked"
|
||||
})),
|
||||
);
|
||||
assert!(oauth_error(&reply, "https://example.invalid/token").is_credential_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_oauth_body_is_network_error() {
|
||||
let reply = reply(500, None);
|
||||
assert_eq!(
|
||||
oauth_error(&reply, "https://example.invalid/token").code(),
|
||||
"network"
|
||||
);
|
||||
}
|
||||
|
||||
fn reply(status: u16, body: Option<serde_json::Value>) -> Reply {
|
||||
Reply {
|
||||
status,
|
||||
body,
|
||||
content_type: Some("application/json".to_string()),
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
//! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义
|
||||
//! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。
|
||||
//!
|
||||
//! 分六块,各管一件事:
|
||||
//! - [`client_id`]:Azure 公共客户端 id 的**配置状态**(来源 / 配没配 / 启动日志那一行,零网络);
|
||||
//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
|
||||
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
|
||||
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
|
||||
//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token);
|
||||
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
|
||||
//!
|
||||
//! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`],
|
||||
//! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。
|
||||
//!
|
||||
//! # client id 从哪来
|
||||
//!
|
||||
//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、
|
||||
//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的
|
||||
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
|
||||
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
|
||||
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`],文案是
|
||||
//! [`client_id::MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配,见 [`client_id`])——
|
||||
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
|
||||
//!
|
||||
//! 措辞纪律(M117):id 缺失是**没配置**,不是「本期未支持」。判定与那行启动日志都在
|
||||
//! [`client_id`],本模块只消费结论。
|
||||
|
||||
pub mod authcode;
|
||||
pub mod callback;
|
||||
pub mod chain;
|
||||
pub mod client_id;
|
||||
pub mod endpoints;
|
||||
pub mod flow;
|
||||
|
||||
pub mod session;
|
||||
|
||||
pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri};
|
||||
pub use callback::CallbackServer;
|
||||
// client id 的判定与启动日志行留一层 re-export:调用方(CLI / GUI / 启动日志)只认
|
||||
// `auth::microsoft::*` 这一条路径,不必知道它是单独一块(M117)。
|
||||
pub use client_id::{
|
||||
client_id_status, resolve_client_id, ClientIdSource, ClientIdStatus, CLIENT_ID_BUILD,
|
||||
CLIENT_ID_ENV, MISSING_CLIENT_ID_MESSAGE,
|
||||
};
|
||||
pub use endpoints::MicrosoftEndpoints;
|
||||
pub use flow::{DeviceCodePrompt, PollPolicy};
|
||||
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::journal::{AuthFailureEvent, AuthJournal};
|
||||
use crate::download::config::EngineConfig;
|
||||
use crate::download::http::build_client;
|
||||
|
||||
/// 微软账户登录/刷新。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MicrosoftAuth {
|
||||
client: Client,
|
||||
client_id: String,
|
||||
endpoints: MicrosoftEndpoints,
|
||||
poll: PollPolicy,
|
||||
authcode: AuthCodePolicy,
|
||||
journal: Option<AuthJournal>,
|
||||
}
|
||||
|
||||
impl MicrosoftAuth {
|
||||
/// 按显式 client id 构造(HTTP 基座复用 `core::download` 的客户端构造)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// HTTP 客户端建不起来时返回 [`AuthError::Network`]。
|
||||
pub fn new(client_id: impl Into<String>) -> AuthResult<Self> {
|
||||
let client = build_client(&EngineConfig::default())
|
||||
.map_err(|error| AuthError::network("http-client", error.to_string()))?;
|
||||
Ok(Self {
|
||||
client,
|
||||
client_id: client_id.into(),
|
||||
endpoints: MicrosoftEndpoints::consumers(),
|
||||
poll: PollPolicy::default(),
|
||||
authcode: AuthCodePolicy::default(),
|
||||
journal: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从环境/构建期取 client id(CLI 与 GUI 的默认入口)。
|
||||
///
|
||||
/// 与 [`Self::new`] 的唯一差别:这条**生产入口**默认挂上便携数据根的失败日志
|
||||
/// (`.ULL/logs/auth.jsonl`,见 [`AuthJournal::portable_default`]),
|
||||
/// 交互式命令失败时 403 的状态码/正文摘要不会再随终端滚走。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 两处都没配时返回 [`AuthError::Unsupported`],文案是
|
||||
/// [`MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配)。这条路径**零网络**:
|
||||
/// id 判定在网络之前,判断失败就地早退,连 HTTP 客户端都不建。
|
||||
pub fn from_env() -> AuthResult<Self> {
|
||||
let Some((client_id, _source)) = resolve_client_id() else {
|
||||
return Err(AuthError::Unsupported(
|
||||
MISSING_CLIENT_ID_MESSAGE.to_string(),
|
||||
));
|
||||
};
|
||||
let mut auth = Self::new(client_id)?;
|
||||
auth.journal = AuthJournal::portable_default();
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
/// 换失败日志落点(`None` = 不落盘;CI/单测用)。
|
||||
#[must_use]
|
||||
pub fn with_journal(mut self, journal: Option<AuthJournal>) -> Self {
|
||||
self.journal = journal;
|
||||
self
|
||||
}
|
||||
|
||||
/// 关掉失败日志。
|
||||
#[must_use]
|
||||
pub fn without_journal(mut self) -> Self {
|
||||
self.journal = None;
|
||||
self
|
||||
}
|
||||
|
||||
/// 失败日志落点(测试断言/宿主展示用)。
|
||||
#[must_use]
|
||||
pub fn journal(&self) -> Option<&AuthJournal> {
|
||||
self.journal.as_ref()
|
||||
}
|
||||
|
||||
/// 换 HTTP 客户端(测试注入 / 复用宿主连接池)。
|
||||
#[must_use]
|
||||
pub fn with_client(mut self, client: Client) -> Self {
|
||||
self.client = client;
|
||||
self
|
||||
}
|
||||
|
||||
/// 换端点表(测试整表指向 mock 服务器)。
|
||||
#[must_use]
|
||||
pub fn with_endpoints(mut self, endpoints: MicrosoftEndpoints) -> Self {
|
||||
self.endpoints = endpoints;
|
||||
self
|
||||
}
|
||||
|
||||
/// 换轮询策略(测试用小间隔)。
|
||||
#[must_use]
|
||||
pub fn with_poll_policy(mut self, poll: PollPolicy) -> Self {
|
||||
self.poll = poll;
|
||||
self
|
||||
}
|
||||
|
||||
/// 换授权码流参数(回调超时 / 固定端口 / 绑定重试 / 固定 state;测试与 CLI 用)。
|
||||
#[must_use]
|
||||
pub fn with_authcode_policy(mut self, policy: AuthCodePolicy) -> Self {
|
||||
self.authcode = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// 当前端点表。
|
||||
#[must_use]
|
||||
pub fn endpoints(&self) -> &MicrosoftEndpoints {
|
||||
&self.endpoints
|
||||
}
|
||||
|
||||
/// device-code 登录全流程。
|
||||
///
|
||||
/// `present` 在拿到用户码后**立刻**被调用(CLI 打到 stderr、GUI 弹框/二维码),
|
||||
/// 然后才开始轮询——用户有整整 `expires_in` 的时间去另一台设备上输码。
|
||||
///
|
||||
/// 失败时除返回错误外,还会往失败日志(若配了)追加一条结构化记录。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 网络/超时/取消/被拒/无拥有权/无档案,见 [`AuthError`]。
|
||||
pub async fn device_code_login<F>(&self, mut present: F) -> AuthResult<MicrosoftSession>
|
||||
where
|
||||
F: FnMut(&DeviceCodePrompt),
|
||||
{
|
||||
let result = self.login_and_chain(&mut present).await;
|
||||
if let Err(error) = &result {
|
||||
self.record_failure("device_code_login", error);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 登录主体(`device_code_login` 的失败日志由外层统一记)。
|
||||
async fn login_and_chain(
|
||||
&self,
|
||||
present: &mut impl FnMut(&DeviceCodePrompt),
|
||||
) -> AuthResult<MicrosoftSession> {
|
||||
let grant =
|
||||
flow::request_device_code(&self.client, &self.endpoints, &self.client_id).await?;
|
||||
present(&grant.prompt);
|
||||
let tokens = flow::poll_device_token(
|
||||
&self.client,
|
||||
&self.endpoints,
|
||||
&self.client_id,
|
||||
&grant,
|
||||
self.poll,
|
||||
)
|
||||
.await?;
|
||||
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await
|
||||
}
|
||||
|
||||
/// 授权码流登录(浏览器 + 本地回调,`research/08` §3 方案 A,GUI 的默认入口)。
|
||||
///
|
||||
/// `present` 在端口绑定之后、等待回调之前被调用:宿主在这一刻拉起系统默认浏览器
|
||||
/// (core 不做 UI,也不假设有 GUI——CLI 可以只把 URL 打到 stderr)。
|
||||
/// 失败时与 [`Self::device_code_login`] 一样往失败日志追加一条结构化记录(M38)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 端口绑不上/网络失败、等回调超时、用户在授权页取消、OAuth 拒绝,以及整链的
|
||||
/// [`AuthError`](含白名单未批时 `login_with_xbox` 的 403 诊断)。
|
||||
pub async fn authcode_login<F>(&self, present: F) -> AuthResult<MicrosoftSession>
|
||||
where
|
||||
F: FnOnce(&AuthCodePrompt),
|
||||
{
|
||||
let result = authcode::login(
|
||||
&self.client,
|
||||
&self.endpoints,
|
||||
&self.client_id,
|
||||
self.authcode.clone(),
|
||||
present,
|
||||
)
|
||||
.await;
|
||||
if let Err(error) = &result {
|
||||
self.record_failure("authcode_login", error);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 凭据被撤销时返回 [`AuthError::CredentialExpired`](唯一该重新交互的信号)。
|
||||
pub async fn refresh(&self, session: &MicrosoftSession) -> AuthResult<MicrosoftSession> {
|
||||
let result = self.refresh_and_chain(session).await;
|
||||
if let Err(error) = &result {
|
||||
self.record_failure("refresh", error);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 刷新主体(失败日志由外层统一记)。
|
||||
async fn refresh_and_chain(&self, session: &MicrosoftSession) -> AuthResult<MicrosoftSession> {
|
||||
let tokens = flow::refresh_tokens(
|
||||
&self.client,
|
||||
&self.endpoints,
|
||||
&self.client_id,
|
||||
&session.refresh_token,
|
||||
)
|
||||
.await?;
|
||||
let refreshed =
|
||||
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await?;
|
||||
if !session.same_profile(&refreshed) {
|
||||
// 防「刷出来是另一个账号」(承 HMCL WrongAccountException)。
|
||||
return Err(AuthError::Malformed(format!(
|
||||
"刷新后档案变了({} → {})",
|
||||
session.profile_id, refreshed.profile_id
|
||||
)));
|
||||
}
|
||||
Ok(refreshed)
|
||||
}
|
||||
|
||||
/// 尽力而为地记一条失败事件(写日志失败绝不影响登录本身的结论)。
|
||||
fn record_failure(&self, scope: &str, error: &AuthError) {
|
||||
if let Some(journal) = &self.journal {
|
||||
let _ = journal.record(&AuthFailureEvent::of(scope, error));
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络侧校验:`GET /minecraft/profile` 是否还认这个 token。
|
||||
///
|
||||
/// 404 的二义在这里**不吞**:档案被删是真实错误,不该被当成「token 失效」静默刷新
|
||||
/// (`analyse/04` §11 观察项 3 那个隐式约定,我们写明)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 网络失败、无拥有权、无档案等。
|
||||
pub async fn validate(&self, session: &MicrosoftSession) -> AuthResult<bool> {
|
||||
if session.is_expired(now_ms()) {
|
||||
return Ok(false);
|
||||
}
|
||||
match chain::fetch_profile(&self.client, &self.endpoints, &session.access_token).await {
|
||||
Ok(_) => Ok(true),
|
||||
// 「档案被删 / 没买游戏」是真实错误,穿透上抛(别静默当成 token 失效去刷新)。
|
||||
Err(error @ AuthError::Denied { .. }) => {
|
||||
let fatal = matches!(
|
||||
&error,
|
||||
AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license"
|
||||
);
|
||||
if fatal {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
// 链上非 2xx:拦截页(HTML/纯文本)说明网络有问题,别伪装成「token 失效」;
|
||||
// 服务端的 JSON 拒绝 / 空体仍按「token 不被接受」处理(与 M31 语义一致)。
|
||||
Err(error @ AuthError::Response(_)) => {
|
||||
let interception = matches!(
|
||||
&error,
|
||||
AuthError::Response(detail) if detail.is_interception()
|
||||
);
|
||||
if interception {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Err(other) => Err(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// 需要就刷新(本地判定,不打网络),返回 `(会话, 是否刷新过)`。
|
||||
///
|
||||
/// 这是 GUI/CLI 的「静默续期」入口:`Reuse` 时零网络,`Refresh` 时整链重跑。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 刷新失败时原样上抛(含 [`AuthError::CredentialExpired`])。
|
||||
pub async fn ensure_fresh(
|
||||
&self,
|
||||
session: &MicrosoftSession,
|
||||
) -> AuthResult<(MicrosoftSession, Option<RefreshReason>)> {
|
||||
match session.refresh_decision(now_ms()) {
|
||||
RefreshDecision::Reuse => Ok((session.clone(), None)),
|
||||
RefreshDecision::Refresh(reason) => {
|
||||
let refreshed = self.refresh(session).await?;
|
||||
Ok((refreshed, Some(reason)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前 Unix 毫秒。
|
||||
#[must_use]
|
||||
pub fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{now_ms, MicrosoftAuth};
|
||||
|
||||
#[test]
|
||||
fn explicit_client_id_builds() {
|
||||
let auth = MicrosoftAuth::new("0000-test-client").expect("构造");
|
||||
assert!(auth
|
||||
.endpoints()
|
||||
.profile
|
||||
.contains("api.minecraftservices.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_ms_is_sane() {
|
||||
// 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。
|
||||
let now = now_ms();
|
||||
assert!(now > 1_577_836_800_000);
|
||||
assert!(now < 4_102_444_800_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! 微软会话与刷新语义:**HMCL 三层判定的类型化版本**(`analyse/04` §5.3 / §3.5 / §8.4)。
|
||||
//!
|
||||
//! 三层判定原样平移:
|
||||
//! 1. `has_profile_name()`:没有玩家名就没法启动(`--username` 不能为空);
|
||||
//! 2. `not_after`:本地时间戳过期即视为未登录(省一次网络);
|
||||
//! 3. 进程内 `authenticated` 标记归宿主(GUI/CLI)持有,不在本类型里。
|
||||
//!
|
||||
//! 关键语义(照 HMCL,别自己发明):
|
||||
//! - 存下来的 `refresh_token` 是 **Live(MSA)** 的,`access_token` 是 **Minecraft** 的;
|
||||
//! - 刷新 = 整条链重跑(Live refresh → XBL → XSTS → login_with_xbox → entitlements → profile),
|
||||
//! 不存在「只刷 MC token」的捷径;
|
||||
//! - `profile_name` 属于**可变的私有缓存**(2022 年后允许改名),元数据里只留不可变的 profileID。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::secret::Secret;
|
||||
use crate::launch::request::AuthPlaceholders;
|
||||
|
||||
/// 微软账户的 launch 用户类型(`AuthInfo.USER_TYPE_MSA`)。
|
||||
pub const USER_TYPE_MSA: &str = "msa";
|
||||
|
||||
/// 一次成功登录后的会话(= 该账户的凭据载荷)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MicrosoftSession {
|
||||
/// Minecraft 档案 UUID(32 位无连字符)。**不可变**。
|
||||
pub profile_id: String,
|
||||
/// 玩家名(可空串:磁盘恢复允许「有档案但名字未知」,登录时补齐)。
|
||||
#[serde(default)]
|
||||
pub profile_name: String,
|
||||
/// MC token 类型(`Bearer`)。
|
||||
pub token_type: String,
|
||||
/// Minecraft access token。
|
||||
pub access_token: Secret,
|
||||
/// Live refresh token(轮换:每次刷新都会被新值整体覆盖)。
|
||||
pub refresh_token: Secret,
|
||||
/// MC token 过期时刻(Unix 毫秒;0 = 未知 → 强制刷新)。
|
||||
pub not_after_ms: u64,
|
||||
/// Xbox user id(`XUID`,诊断/排障用,非凭据)。
|
||||
pub user_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 过期判定的结论。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefreshDecision {
|
||||
/// 本地认为可用(有玩家名且未过期):直接复用,不打网络。
|
||||
Reuse,
|
||||
/// 需要整链重跑。
|
||||
Refresh(RefreshReason),
|
||||
}
|
||||
|
||||
/// 为什么要刷新。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefreshReason {
|
||||
/// 玩家名缺失(改名或历史档案)——启动参数拿不到 `--username`。
|
||||
MissingProfileName,
|
||||
/// 本地时间戳已过期。
|
||||
Expired,
|
||||
/// 名字缺失**且**过期(两条理由都成立)。
|
||||
MissingProfileNameAndExpired,
|
||||
}
|
||||
|
||||
impl RefreshReason {
|
||||
/// 人读一行。
|
||||
#[must_use]
|
||||
pub const fn message(self) -> &'static str {
|
||||
match self {
|
||||
Self::MissingProfileName => "会话缺少玩家名,需要刷新",
|
||||
Self::Expired => "Minecraft 令牌已过期,需要刷新",
|
||||
Self::MissingProfileNameAndExpired => "会话缺少玩家名且令牌已过期,需要刷新",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MicrosoftSession {
|
||||
/// 构造(`expires_in` 是秒,`now_ms` 是当前 Unix 毫秒)。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
profile_id: impl Into<String>,
|
||||
profile_name: impl Into<String>,
|
||||
access_token: Secret,
|
||||
refresh_token: Secret,
|
||||
expires_in_secs: u64,
|
||||
now_ms: u64,
|
||||
user_id: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
profile_id: profile_id.into(),
|
||||
profile_name: profile_name.into(),
|
||||
token_type: "Bearer".to_string(),
|
||||
access_token,
|
||||
refresh_token,
|
||||
not_after_ms: now_ms.saturating_add(expires_in_secs.saturating_mul(1000)),
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// 有没有玩家名(空串/全空白都算没有)。
|
||||
#[must_use]
|
||||
pub fn has_profile_name(&self) -> bool {
|
||||
!self.profile_name.trim().is_empty()
|
||||
}
|
||||
|
||||
/// 本地是否过期(`not_after_ms == 0` 视为过期,承 HMCL「坏数据强制刷新」)。
|
||||
#[must_use]
|
||||
pub fn is_expired(&self, now_ms: u64) -> bool {
|
||||
self.not_after_ms == 0 || now_ms >= self.not_after_ms
|
||||
}
|
||||
|
||||
/// 刷新判定(纯函数,可单测;不打网络)。
|
||||
#[must_use]
|
||||
pub fn refresh_decision(&self, now_ms: u64) -> RefreshDecision {
|
||||
match (self.has_profile_name(), self.is_expired(now_ms)) {
|
||||
(true, false) => RefreshDecision::Reuse,
|
||||
(false, false) => RefreshDecision::Refresh(RefreshReason::MissingProfileName),
|
||||
(true, true) => RefreshDecision::Refresh(RefreshReason::Expired),
|
||||
(false, true) => RefreshDecision::Refresh(RefreshReason::MissingProfileNameAndExpired),
|
||||
}
|
||||
}
|
||||
|
||||
/// 该会话所有凭据值(武装脱敏器用)。
|
||||
#[must_use]
|
||||
pub fn secrets(&self) -> Vec<&Secret> {
|
||||
vec![&self.access_token, &self.refresh_token]
|
||||
}
|
||||
|
||||
/// 转成 launch 的占位符集合(`user_properties` 为 `{}`,承 HMCL)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 会话缺少玩家名/UUID 时返回 [`AuthError::CredentialExpired`]——此时必须刷新,
|
||||
/// 不能拿空名字去拼命令行(`--username` 空串会导致游戏直接退出)。
|
||||
pub fn to_placeholders(&self) -> AuthResult<AuthPlaceholders> {
|
||||
if !self.has_profile_name() {
|
||||
return Err(AuthError::CredentialExpired);
|
||||
}
|
||||
if self.profile_id.is_empty() {
|
||||
return Err(AuthError::Malformed("会话缺少 profileID".to_string()));
|
||||
}
|
||||
Ok(AuthPlaceholders {
|
||||
player_name: self.profile_name.clone(),
|
||||
uuid: self.profile_id.clone(),
|
||||
access_token: self.access_token.expose().to_string(),
|
||||
user_type: USER_TYPE_MSA.to_string(),
|
||||
user_properties: "{}".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 刷新后守住两条不变量(`analyse/04` §5.3 的 `WrongAccountException` / `hasProfileName`)。
|
||||
///
|
||||
/// 返回 `false` 表示「刷出来的不是同一个档案」,调用方必须当错误处理。
|
||||
#[must_use]
|
||||
pub fn same_profile(&self, other: &Self) -> bool {
|
||||
self.profile_id == other.profile_id
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MicrosoftSession, RefreshDecision, RefreshReason};
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
fn session(name: &str, not_after_ms: u64) -> MicrosoftSession {
|
||||
MicrosoftSession {
|
||||
profile_id: "0f8fad5bd9cb469fa16570867728950e".to_string(),
|
||||
profile_name: name.to_string(),
|
||||
token_type: "Bearer".to_string(),
|
||||
access_token: Secret::new("mc-token-value"),
|
||||
refresh_token: Secret::new("live-refresh-value"),
|
||||
not_after_ms,
|
||||
user_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_matrix() {
|
||||
let now = 1_000_000;
|
||||
assert_eq!(
|
||||
session("Steve", now + 1).refresh_decision(now),
|
||||
RefreshDecision::Reuse
|
||||
);
|
||||
assert_eq!(
|
||||
session("", now + 1).refresh_decision(now),
|
||||
RefreshDecision::Refresh(RefreshReason::MissingProfileName)
|
||||
);
|
||||
assert_eq!(
|
||||
session("Steve", now - 1).refresh_decision(now),
|
||||
RefreshDecision::Refresh(RefreshReason::Expired)
|
||||
);
|
||||
assert_eq!(
|
||||
session(" ", now).refresh_decision(now),
|
||||
RefreshDecision::Refresh(RefreshReason::MissingProfileNameAndExpired)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_not_after_forces_refresh() {
|
||||
assert_eq!(
|
||||
session("Steve", 0).refresh_decision(5),
|
||||
RefreshDecision::Refresh(RefreshReason::Expired)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_computes_not_after_from_expires_in() {
|
||||
let built = MicrosoftSession::new(
|
||||
"uuid",
|
||||
"Steve",
|
||||
Secret::new("a"),
|
||||
Secret::new("r"),
|
||||
86_400,
|
||||
1_000,
|
||||
None,
|
||||
);
|
||||
assert_eq!(built.not_after_ms, 1_000 + 86_400_000);
|
||||
assert_eq!(built.token_type, "Bearer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_need_a_name() {
|
||||
assert!(session("", 10).to_placeholders().is_err());
|
||||
let placeholders = session("Steve", 10).to_placeholders().expect("占位符");
|
||||
assert_eq!(placeholders.player_name, "Steve");
|
||||
assert_eq!(placeholders.user_type, "msa");
|
||||
assert_eq!(placeholders.user_properties, "{}");
|
||||
// token 进了占位符(启动需要),但它自己的 Debug 是脱敏的。
|
||||
assert_eq!(placeholders.access_token, "mc-token-value");
|
||||
assert!(!format!("{:?}", session("Steve", 10)).contains("mc-token-value"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! 账户体系:三轨(离线 UUID / 微软 device-code 与授权码 / authlib-injector)、凭据存储边界。
|
||||
//!
|
||||
//! 设计定稿:`DevDocs/PlanDoc/07-accounts.md`(决策 D9/Q21),协议细节来自
|
||||
//! `DevDocs/analyse/04-authentication.md`(HMCL v3.17 拆解,逐报文对照)。
|
||||
//!
|
||||
//! # 三条铁律
|
||||
//!
|
||||
//! 1. **凭据永不外泄**:token/密码不落日志、不进事件流、不打印(`AGENTS.md` §6、D4)。
|
||||
//! 类型层保证:[`Secret`] 的 `Debug`/`Display` 恒为 `<redacted>`,取明文只能 `.expose()`;
|
||||
//! 对外展示一律走元数据([`AccountMeta`],不含凭据)。
|
||||
//! 2. **账户操作永不 YOLO**:本模块不向 AI 暴露任何账户读写面(07 §3);
|
||||
//! CLI 也只开 `list / add offline / add msa / remove` 四条(authlib 密码登录走 GUI 输入框,
|
||||
//! 避免密码进 argv)。
|
||||
//! 3. **元数据 / 凭据分文件**:`data/auth/accounts.json` + `data/auth/credentials.dat`,
|
||||
//! 先写凭据后写元数据,原子写,单条坏记录只跳过不整体失败。
|
||||
//!
|
||||
//! # 公共面
|
||||
//!
|
||||
//! - [`account`]:`AccountId` / `AccountKind` / `AccountMeta` / `Credentials`(三轨统一抽象);
|
||||
//! - [`store`]:`AccountStore`(加载/增删/落盘,`data/auth/`);
|
||||
//! - [`offline`]:HMCL 兼容离线 UUID + 启动占位;
|
||||
//! - [`microsoft`]:微软两条登录流(device-code / 授权码+本地回调)+ 共用后链
|
||||
//! (XBL→XSTS→login_with_xbox→entitlements→profile)+ 刷新判定;
|
||||
//! - [`authlib`]:标准 Yggdrasil 协议(authenticate/refresh/validate/invalidate,账户侧);
|
||||
//! - [`error`]:错误分流(取消 / 超时 / 被拒 / 凭据过期);
|
||||
//! - [`body`]:非 2xx 应答的正文诊断(分类 JSON/HTML/空体 + 脱敏摘要,M38);
|
||||
//! - [`journal`]:认证失败事件落盘(`.ULL/logs/auth.jsonl`,M38);
|
||||
//! - [`secret`]:凭据值包装与脱敏。
|
||||
//!
|
||||
//! # 本期不做(后续 mission)
|
||||
//!
|
||||
//! OS keychain / AES-256-GCM 凭据加密(07 §2 分级 1–2)、authlib-injector 的
|
||||
//! `-javaagent` 三件套与 jar 下载、皮肤与多角色选择、离线 UUID 的「官方风格」双模式
|
||||
//! (07 §1 升级 2,口径待定案)。接口约定见 `DevDocs/SubAgentsTalk/auth-接口约定.md`。
|
||||
|
||||
pub mod account;
|
||||
pub mod authlib;
|
||||
pub mod body;
|
||||
pub mod error;
|
||||
pub mod http;
|
||||
pub mod journal;
|
||||
pub mod microsoft;
|
||||
pub mod offline;
|
||||
pub mod secret;
|
||||
pub mod store;
|
||||
|
||||
pub use account::{AccountId, AccountKind, AccountMeta, Credentials};
|
||||
pub use body::{BodyKind, ResponseDetail};
|
||||
pub use error::{AuthError, AuthResult};
|
||||
pub use journal::{AuthFailureEvent, AuthJournal};
|
||||
pub use microsoft::{
|
||||
AuthCodePolicy, AuthCodePrompt, MicrosoftAuth, MicrosoftSession, RefreshDecision, RefreshReason,
|
||||
};
|
||||
pub use secret::Secret;
|
||||
pub use store::{AccountStore, AuthPaths};
|
||||
@@ -0,0 +1,197 @@
|
||||
//! 离线轨:**HMCL / Minecraft 服务端同款离线 UUID** + 启动占位字段。
|
||||
//!
|
||||
//! UUID 算法(`analyse/04` §7.1):`UUID.nameUUIDFromBytes("OfflinePlayer:" + name)`
|
||||
//! = 对 UTF-8 字节做 MD5,再按 RFC 4122 改写成 **v3** 版本位与 **IETF 变体**位。
|
||||
//! 必须逐字节一致:离线服务器的 `ops.json` / 白名单 / 领地数据全按这个 UUID 存,
|
||||
//! 算错就对不上(皮肤、权限、存档全乱)。因此有与 JDK `UUID.nameUUIDFromBytes`
|
||||
//! 对拍的已知向量测试(见 `crates/core/tests/auth/offline_uuid.rs`)。
|
||||
//!
|
||||
//! 启动占位(`analyse/04` §7.2,承 `OfflineAccount.logInWithoutSkin`):
|
||||
//! - `accessToken` = **每次登录随机生成的 32 位十六进制**(游戏客户端不接受空 token);
|
||||
//! - `userType` = `msa`(HMCL 现版本如此;历史注释提过 `legacy`,但那是旧结论);
|
||||
//! - `userProperties` = `{}`;
|
||||
//! - `uuid` 用**无连字符** 32 位形式(`--uuid` 参数要求)。
|
||||
|
||||
use md5::{Digest, Md5};
|
||||
|
||||
use crate::auth::account::{AccountKind, AccountMeta};
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::secret::{random_hex32, Secret};
|
||||
use crate::launch::request::AuthPlaceholders;
|
||||
|
||||
/// 离线 UUID 的固定前缀(改一个字节就会与所有服务端对不上)。
|
||||
pub const OFFLINE_PREFIX: &str = "OfflinePlayer:";
|
||||
/// 离线账户的 launch 用户类型(`AuthInfo.USER_TYPE_MSA`)。
|
||||
pub const OFFLINE_USER_TYPE: &str = "msa";
|
||||
/// 玩家名长度上限(MC 客户端 `--username` 的实际上限)。
|
||||
pub const MAX_NAME_CHARS: usize = 16;
|
||||
|
||||
/// 算离线 UUID(**32 位无连字符小写十六进制**)。
|
||||
#[must_use]
|
||||
pub fn offline_uuid(name: &str) -> String {
|
||||
let digest = Md5::digest(format!("{OFFLINE_PREFIX}{name}").as_bytes());
|
||||
let mut bytes = [0_u8; 16];
|
||||
bytes.copy_from_slice(&digest);
|
||||
|
||||
// RFC 4122 §4.3:版本位 3(MD5 based)、变体位 IETF(10xx)。
|
||||
bytes[6] = (bytes[6] & 0x0F) | 0x30;
|
||||
bytes[8] = (bytes[8] & 0x3F) | 0x80;
|
||||
|
||||
let mut hex = String::with_capacity(32);
|
||||
for byte in bytes {
|
||||
hex.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
/// 带连字符的离线 UUID(展示/诊断用;启动参数要的是无连字符版)。
|
||||
#[must_use]
|
||||
pub fn offline_uuid_dashed(name: &str) -> String {
|
||||
let raw = offline_uuid(name);
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&raw[0..8],
|
||||
&raw[8..12],
|
||||
&raw[12..16],
|
||||
&raw[16..20],
|
||||
&raw[20..32]
|
||||
)
|
||||
}
|
||||
|
||||
/// 校验并规范化离线玩家名。
|
||||
///
|
||||
/// 规则:非空、去掉首尾空白后 ≤16 字符、不含控制字符。
|
||||
/// **允许非 ASCII**(中文名在离线服里很常见,UUID 按 UTF-8 字节算即可)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 空名/过长/含控制字符时返回 [`AuthError::Malformed`]。
|
||||
pub fn normalize_name(name: &str) -> AuthResult<String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AuthError::Malformed("玩家名不能为空".to_string()));
|
||||
}
|
||||
if trimmed.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(AuthError::Malformed(format!(
|
||||
"玩家名最长 {MAX_NAME_CHARS} 个字符,收到 {} 个",
|
||||
trimmed.chars().count()
|
||||
)));
|
||||
}
|
||||
if trimmed.chars().any(char::is_control) {
|
||||
return Err(AuthError::Malformed("玩家名不能含控制字符".to_string()));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// 建一条离线账户元数据(名字已规范化)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 名字不合法时返回 [`AuthError::Malformed`]。
|
||||
pub fn offline_meta(name: &str, created_at_ms: u64) -> AuthResult<AccountMeta> {
|
||||
let name = normalize_name(name)?;
|
||||
Ok(AccountMeta::new(
|
||||
AccountKind::Offline,
|
||||
offline_uuid(&name),
|
||||
name,
|
||||
created_at_ms,
|
||||
))
|
||||
}
|
||||
|
||||
/// 生成一次「登录」的启动占位(不联网、不落盘)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 元数据不是离线轨或名字为空时返回 [`AuthError::Malformed`] / [`AuthError::CredentialExpired`]。
|
||||
pub fn offline_login(meta: &AccountMeta) -> AuthResult<AuthPlaceholders> {
|
||||
if meta.kind != AccountKind::Offline {
|
||||
return Err(AuthError::Malformed(format!(
|
||||
"账户 {} 不是离线轨({})",
|
||||
meta.id,
|
||||
meta.kind.as_str()
|
||||
)));
|
||||
}
|
||||
if meta.profile_name.trim().is_empty() {
|
||||
return Err(AuthError::CredentialExpired);
|
||||
}
|
||||
Ok(AuthPlaceholders {
|
||||
player_name: meta.profile_name.clone(),
|
||||
uuid: meta.profile_id.clone(),
|
||||
// 随机占位:每次登录都变(与 HMCL 一致),不落盘、不进日志。
|
||||
access_token: Secret::new(random_hex32()).expose().to_string(),
|
||||
user_type: OFFLINE_USER_TYPE.to_string(),
|
||||
user_properties: "{}".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
normalize_name, offline_login, offline_meta, offline_uuid, offline_uuid_dashed,
|
||||
OFFLINE_USER_TYPE,
|
||||
};
|
||||
use crate::auth::account::AccountKind;
|
||||
|
||||
#[test]
|
||||
fn uuid_matches_jdk_name_uuid_from_bytes() {
|
||||
// 向量由 JDK 25 的 UUID.nameUUIDFromBytes 生成(见 tests/auth/offline_uuid.rs 的说明)。
|
||||
assert_eq!(
|
||||
offline_uuid("Notch"),
|
||||
"b50ad385829d3141a2167e7d7539ba7f".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
offline_uuid_dashed("Notch"),
|
||||
"b50ad385-829d-3141-a216-7e7d7539ba7f"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_has_v3_and_ietf_bits() {
|
||||
let raw = offline_uuid("AnyPlayer");
|
||||
let version = u8::from_str_radix(&raw[12..13], 16).expect("版本位");
|
||||
assert_eq!(version, 3, "必须标记为 v3(MD5)");
|
||||
let variant = u8::from_str_radix(&raw[16..17], 16).expect("变体位");
|
||||
assert!(
|
||||
(0x8..=0xb).contains(&variant),
|
||||
"变体位必须是 IETF(10xx),实际 {variant:x}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_is_stable_and_case_sensitive() {
|
||||
assert_eq!(offline_uuid("Steve"), offline_uuid("Steve"));
|
||||
assert_ne!(offline_uuid("Steve"), offline_uuid("steve"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_validation() {
|
||||
assert_eq!(normalize_name(" Steve ").expect("合法"), "Steve");
|
||||
assert_eq!(normalize_name("小明").expect("中文合法"), "小明");
|
||||
assert!(normalize_name(" ").is_err());
|
||||
assert!(normalize_name("0123456789abcdefg").is_err());
|
||||
assert!(normalize_name("bad\u{7}name").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_meta_and_login_shape() {
|
||||
let meta = offline_meta("小明", 1_000).expect("建元数据");
|
||||
assert_eq!(meta.kind, AccountKind::Offline);
|
||||
assert_eq!(meta.profile_id, offline_uuid("小明"));
|
||||
let placeholders = offline_login(&meta).expect("占位符");
|
||||
assert_eq!(placeholders.player_name, "小明");
|
||||
assert_eq!(placeholders.uuid, offline_uuid("小明"));
|
||||
assert_eq!(placeholders.user_type, OFFLINE_USER_TYPE);
|
||||
assert_eq!(placeholders.user_properties, "{}");
|
||||
assert_eq!(placeholders.access_token.len(), 32);
|
||||
// 每次登录的占位 token 都不同(HMCL 行为)。
|
||||
let again = offline_login(&meta).expect("占位符");
|
||||
assert_ne!(placeholders.access_token, again.access_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_login_rejects_other_tracks() {
|
||||
let mut meta = offline_meta("Steve", 1).expect("建元数据");
|
||||
meta.kind = AccountKind::Microsoft;
|
||||
assert!(offline_login(&meta).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! 凭据值包装:**打印出来永远是 `<redacted>`**。
|
||||
//!
|
||||
//! 依据 `DevDocs/PlanDoc/07-accounts.md` §3 与 `DevDocs/AGENTS.md` §6 的凭据红线:
|
||||
//! token/密码永不进日志、事件流、stdout、测试快照。HMCL 靠「往日志过滤器注册 token」
|
||||
//! (`analyse/04` §11 观察项 9:绕开 Logger 就失效),我们在**类型层**堵:
|
||||
//! 只要某值被包成 [`Secret`],`Debug`/`Display`/`{:?}`/`{}` 都拿不到明文,
|
||||
//! 取明文必须显式写 `.expose()`(一眼可搜、CR 可审)。
|
||||
//!
|
||||
//! 落盘仍然需要明文(凭据文件),因此保留 serde 透明序列化——序列化不是打印。
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::launch::redact::{Redactor, TOKEN_PLACEHOLDER};
|
||||
|
||||
/// `Debug`/`Display` 与脱敏替换的统一标记。
|
||||
pub const REDACTED: &str = "<redacted>";
|
||||
|
||||
/// 一个凭据值(access token / refresh token / client token / 密码)。
|
||||
///
|
||||
/// 相等性按明文比较(刷新后判断「token 有没有变」要用到),哈希同理。
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Secret(String);
|
||||
|
||||
impl Secret {
|
||||
/// 包住一个明文值。
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
/// 取明文。**唯一取明文入口**:出现处都应能说清「这是发请求,不是打印」。
|
||||
#[must_use]
|
||||
pub fn expose(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// 是否为空(空 token 等价于「没有凭据」)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// 明文字节长度(判空/判占位长度用,不泄露内容)。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Secret {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(REDACTED)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Secret {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(REDACTED)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Secret {
|
||||
fn from(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Secret {
|
||||
fn from(value: &str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 32 个十六进制字符的随机串(离线 accessToken 占位、条目 id)。
|
||||
///
|
||||
/// **不是密码学随机**(同 `game::ProfileId::generate` 的取舍):混合纳秒时间戳、进程 id、
|
||||
/// 进程内计数器后过 SplitMix64。这些值不参与任何服务端校验(HMCL 同样用
|
||||
/// `UUID.randomUUID().toString()` 的紧凑形式当占位),唯一性够用即可。
|
||||
#[must_use]
|
||||
pub fn random_hex32() -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_nanos() as u64);
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let mut seed = nanos ^ u64::from(std::process::id()).rotate_left(17) ^ seq.rotate_left(41);
|
||||
|
||||
let mut out = String::with_capacity(32);
|
||||
let mut state = seed;
|
||||
while out.len() < 32 {
|
||||
// 两个 u64 混合出 16 个 hex 字符一轮。
|
||||
state = split_mix64(state);
|
||||
seed ^= split_mix64(seed.rotate_left(29) ^ state);
|
||||
out.push_str(&format!("{:016x}", seed));
|
||||
}
|
||||
out.truncate(32);
|
||||
out
|
||||
}
|
||||
|
||||
fn split_mix64(seed: u64) -> u64 {
|
||||
let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// 用一组凭据武装一个日志脱敏器(`launch::redact::Redactor` 的唯一构造入口)。
|
||||
///
|
||||
/// 太短的值(< 6 字节)会被 `Redactor` 自动忽略,避免把正常文本搅乱。
|
||||
#[must_use]
|
||||
pub fn redactor_for<'a>(secrets: impl IntoIterator<Item = &'a Secret>) -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
for secret in secrets {
|
||||
if !secret.is_empty() {
|
||||
redactor = redactor.with_secret(secret.expose().to_string());
|
||||
}
|
||||
}
|
||||
redactor
|
||||
}
|
||||
|
||||
/// 对一段文本做凭据脱敏(走 `launch::redact` 的三条规则:已登记凭据、长度 ≥64 不透明串、
|
||||
/// 带点 token/JWT)。
|
||||
#[must_use]
|
||||
pub fn scrub<'a>(text: &'a str, secrets: &[&Secret]) -> Cow<'a, str> {
|
||||
let owned: Vec<Secret> = secrets.iter().map(|secret| (*secret).clone()).collect();
|
||||
redactor_for(owned.iter()).scrub(text)
|
||||
}
|
||||
|
||||
/// 占位常量转出(给调用方断言 / 拼提示用)。
|
||||
#[must_use]
|
||||
pub const fn token_placeholder() -> &'static str {
|
||||
TOKEN_PLACEHOLDER
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{random_hex32, scrub, Secret, REDACTED, TOKEN_PLACEHOLDER};
|
||||
|
||||
#[test]
|
||||
fn debug_and_display_never_leak() {
|
||||
let secret = Secret::new("SUPER-SECRET-TOKEN-VALUE");
|
||||
assert_eq!(format!("{secret:?}"), REDACTED);
|
||||
assert_eq!(format!("{secret}"), REDACTED);
|
||||
assert!(!format!("{secret:?}").contains("SUPER"));
|
||||
let bundle = format!("{:?}", vec![secret.clone()]);
|
||||
assert!(!bundle.contains("SECRET"), "{bundle}");
|
||||
assert_eq!(secret.expose(), "SUPER-SECRET-TOKEN-VALUE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_keeps_plaintext() {
|
||||
let secret = Secret::new("abc123");
|
||||
let text = serde_json::to_string(&secret).expect("序列化");
|
||||
assert_eq!(text, "\"abc123\"");
|
||||
let back: Secret = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrub_replaces_registered_secret() {
|
||||
let secret = Secret::new("0f8fad5bd9cb469fa16570867728950e");
|
||||
let text = format!("--accessToken {} --uuid abc", secret.expose());
|
||||
let scrubbed = scrub(&text, &[&secret]).into_owned();
|
||||
assert!(!scrubbed.contains("0f8fad"));
|
||||
assert!(scrubbed.contains(TOKEN_PLACEHOLDER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_hex32_is_32_hex_and_unique() {
|
||||
let first = random_hex32();
|
||||
let second = random_hex32();
|
||||
assert_eq!(first.len(), 32);
|
||||
assert!(first.chars().all(|ch| ch.is_ascii_hexdigit()));
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! 账户存储:`data/auth/` 下的**元数据 / 凭据分文件**落盘(内存视图与增删)。
|
||||
//!
|
||||
//! 契约(`PlanDoc/07-accounts.md` §2、`PlanDoc/04-data-and-config.md`):
|
||||
//! 1. **分文件**:`accounts.json`(可公开)+ `credentials.dat`(凭据)。列账户不碰凭据文件;
|
||||
//! 2. **先写凭据、再写元数据**(HMCL 安全顺序)——元数据永不指向不存在的凭据;
|
||||
//! 3. **原子写**:走 `platform::atomic::write_atomic`(tmp → fsync → rename);
|
||||
//! 4. **容错**:单条坏记录只跳过该条并留 warning,绝不整体加载失败;
|
||||
//! 5. **孤儿清理**:`save()` 只写「元数据里还存在」的凭据。
|
||||
//!
|
||||
//! 落盘细节(payload 形状、权限收紧)在同目录的 [`payload`] 子模块,本文件只管模型与不变量。
|
||||
//!
|
||||
//! 保护级别:本期 `plain-v1`(分文件 + 权限 + 全链脱敏)。OS keychain / AES-256-GCM
|
||||
//! 分级是 07 §2 的第 1–2 条,需要 keyring/aes-gcm/argon2 的依赖决策,属后续 mission。
|
||||
|
||||
mod payload;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::auth::account::{AccountId, AccountMeta, Credentials};
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
|
||||
/// 元数据文件名(`data/auth/accounts.json`)。
|
||||
pub const ACCOUNTS_FILE: &str = "accounts.json";
|
||||
/// 凭据文件名(`data/auth/credentials.dat`)。
|
||||
pub const CREDENTIALS_FILE: &str = "credentials.dat";
|
||||
/// 元数据 schema 版本。
|
||||
pub const AUTH_SCHEMA_VERSION: u32 = 1;
|
||||
/// 本期凭据保护级别(明文 + 分文件 + 权限 + 脱敏)。
|
||||
pub const PROTECTION_PLAIN_V1: &str = "plain-v1";
|
||||
|
||||
/// `data/auth/` 的两个文件位置。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthPaths {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl AuthPaths {
|
||||
/// 以数据分区为根(`LauncherLayout::auth_data()`)。
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// 根目录。
|
||||
#[must_use]
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// 元数据文件。
|
||||
#[must_use]
|
||||
pub fn accounts_file(&self) -> PathBuf {
|
||||
self.root.join(ACCOUNTS_FILE)
|
||||
}
|
||||
|
||||
/// 凭据文件。
|
||||
#[must_use]
|
||||
pub fn credentials_file(&self) -> PathBuf {
|
||||
self.root.join(CREDENTIALS_FILE)
|
||||
}
|
||||
}
|
||||
|
||||
/// 账户库(内存视图 + 落盘)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountStore {
|
||||
paths: AuthPaths,
|
||||
accounts: Vec<AccountMeta>,
|
||||
credentials: BTreeMap<String, Credentials>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl AccountStore {
|
||||
/// 打开(不存在就是空库;坏记录进 warnings)。
|
||||
#[must_use]
|
||||
pub fn open(root: impl Into<PathBuf>) -> Self {
|
||||
Self::open_paths(AuthPaths::new(root))
|
||||
}
|
||||
|
||||
/// 打开指定文件位置。
|
||||
#[must_use]
|
||||
pub fn open_paths(paths: AuthPaths) -> Self {
|
||||
let mut warnings = Vec::new();
|
||||
let mut accounts = payload::read_accounts(&paths.accounts_file(), &mut warnings);
|
||||
accounts.sort_by_key(|meta| meta.created_at_ms);
|
||||
let known: Vec<String> = accounts
|
||||
.iter()
|
||||
.map(|meta| meta.id.as_str().to_string())
|
||||
.collect();
|
||||
let credentials =
|
||||
payload::read_credentials(&paths.credentials_file(), &known, &mut warnings);
|
||||
Self {
|
||||
paths,
|
||||
accounts,
|
||||
credentials,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件位置。
|
||||
#[must_use]
|
||||
pub fn paths(&self) -> &AuthPaths {
|
||||
&self.paths
|
||||
}
|
||||
|
||||
/// 账户列表(按创建时间排序,展示顺序稳定)。
|
||||
#[must_use]
|
||||
pub fn accounts(&self) -> &[AccountMeta] {
|
||||
&self.accounts
|
||||
}
|
||||
|
||||
/// 加载过程中的告警(坏记录、孤儿凭据等)。
|
||||
#[must_use]
|
||||
pub fn warnings(&self) -> &[String] {
|
||||
&self.warnings
|
||||
}
|
||||
|
||||
/// 是否没有任何账户。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.accounts.is_empty()
|
||||
}
|
||||
|
||||
/// 按名字 / 登录名 / 条目 id 查找(大小写不敏感)。
|
||||
#[must_use]
|
||||
pub fn find(&self, key: &str) -> Option<&AccountMeta> {
|
||||
self.accounts.iter().find(|meta| meta.matches(key))
|
||||
}
|
||||
|
||||
/// 取某条目的凭据。
|
||||
#[must_use]
|
||||
pub fn credentials(&self, id: &AccountId) -> Option<&Credentials> {
|
||||
self.credentials.get(id.as_str())
|
||||
}
|
||||
|
||||
/// 取某条目的「元数据 + 凭据」(找不到凭据时报错)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 条目不存在 → [`AuthError::NotFound`];条目在但凭据缺失 → [`AuthError::Malformed`]
|
||||
/// (说明文件被手工删过,别静默当成「没登录」)。
|
||||
pub fn entry(&self, id: &AccountId) -> AuthResult<(&AccountMeta, &Credentials)> {
|
||||
let meta = self
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|meta| meta.id == *id)
|
||||
.ok_or_else(|| AuthError::NotFound {
|
||||
account: id.to_string(),
|
||||
})?;
|
||||
let credentials = self
|
||||
.credentials
|
||||
.get(id.as_str())
|
||||
.ok_or_else(|| AuthError::Malformed(format!("账户 {id} 的凭据缺失")))?;
|
||||
Ok((meta, credentials))
|
||||
}
|
||||
|
||||
/// 新增账户(内存)。同名同轨冲突时报错——`--account <名字>` 才有唯一解。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 重复、条目 id 重复、轨道与凭据不符都返回错误。
|
||||
pub fn insert(&mut self, meta: AccountMeta, credentials: Credentials) -> AuthResult<()> {
|
||||
meta.check_credentials(&credentials)?;
|
||||
if self.accounts.iter().any(|existing| existing.id == meta.id) {
|
||||
return Err(AuthError::Conflict {
|
||||
account: meta.id.to_string(),
|
||||
});
|
||||
}
|
||||
let duplicate = self
|
||||
.accounts
|
||||
.iter()
|
||||
.any(|existing| existing.kind == meta.kind && existing.matches(meta.display_name()));
|
||||
if duplicate {
|
||||
return Err(AuthError::Conflict {
|
||||
account: meta.display_name().to_string(),
|
||||
});
|
||||
}
|
||||
self.credentials
|
||||
.insert(meta.id.as_str().to_string(), credentials);
|
||||
self.accounts.push(meta);
|
||||
self.accounts.sort_by_key(|meta| meta.created_at_ms);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除账户(连同凭据)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 找不到时返回 [`AuthError::NotFound`]。
|
||||
pub fn remove(&mut self, key: &str) -> AuthResult<AccountMeta> {
|
||||
let index = self
|
||||
.accounts
|
||||
.iter()
|
||||
.position(|meta| meta.matches(key))
|
||||
.ok_or_else(|| AuthError::NotFound {
|
||||
account: key.to_string(),
|
||||
})?;
|
||||
let meta = self.accounts.remove(index);
|
||||
self.credentials.remove(meta.id.as_str());
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// 更新某条目的凭据(刷新后轮换 token 用)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 条目不存在或轨道不符时返回错误。
|
||||
pub fn update_credentials(
|
||||
&mut self,
|
||||
id: &AccountId,
|
||||
credentials: Credentials,
|
||||
) -> AuthResult<()> {
|
||||
let meta = self
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|meta| meta.id == *id)
|
||||
.ok_or_else(|| AuthError::NotFound {
|
||||
account: id.to_string(),
|
||||
})?;
|
||||
meta.check_credentials(&credentials)?;
|
||||
self.credentials
|
||||
.insert(id.as_str().to_string(), credentials);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 同步某条目的元数据字段(刷新补齐了玩家名之后写回)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 条目不存在时返回 [`AuthError::NotFound`]。
|
||||
pub fn update_meta(&mut self, meta: AccountMeta) -> AuthResult<()> {
|
||||
let slot = self
|
||||
.accounts
|
||||
.iter_mut()
|
||||
.find(|existing| existing.id == meta.id)
|
||||
.ok_or_else(|| AuthError::NotFound {
|
||||
account: meta.id.to_string(),
|
||||
})?;
|
||||
*slot = meta;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 落盘:**先凭据、后元数据**。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 两个文件任一写失败都返回 [`AuthError::Store`]。
|
||||
pub fn save(&self) -> AuthResult<()> {
|
||||
payload::write(&self.paths, &self.accounts, &self.credentials)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! 落盘细节:payload 形状、宽容解析、凭据文件权限。
|
||||
//!
|
||||
//! 两个文件的形状(都带 `schemaVersion`,便于以后演进):
|
||||
//!
|
||||
//! ```json
|
||||
//! // accounts.json —— 可公开
|
||||
//! { "schemaVersion": 1, "accounts": [ { "id": "account:…", "kind": "offline",
|
||||
//! "profileId": "…", "profileName": "Steve", "createdAtMs": 1700000000000 } ] }
|
||||
//!
|
||||
//! // credentials.dat —— 凭据
|
||||
//! { "schemaVersion": 1, "protection": "plain-v1",
|
||||
//! "entries": [ { "accountId": "account:…", "data": { "kind": "microsoft", … } } ] }
|
||||
//! ```
|
||||
//!
|
||||
//! `entries[].data` 里是 [`Credentials`] 的序列化结果(由 serde 自己定义形状),
|
||||
//! 本模块只负责「哪条属于哪个账户」。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::account::{AccountMeta, Credentials};
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::platform::atomic::write_atomic;
|
||||
|
||||
use super::{AuthPaths, AUTH_SCHEMA_VERSION, PROTECTION_PLAIN_V1};
|
||||
|
||||
/// 写两处文件:**先凭据、后元数据**。
|
||||
pub(super) fn write(
|
||||
paths: &AuthPaths,
|
||||
accounts: &[AccountMeta],
|
||||
credentials: &BTreeMap<String, Credentials>,
|
||||
) -> AuthResult<()> {
|
||||
let credentials_payload = credentials_payload(accounts, credentials);
|
||||
let path = paths.credentials_file();
|
||||
write_atomic(&path, credentials_payload.as_bytes())
|
||||
.map_err(|error| AuthError::store(path.display(), error.to_string()))?;
|
||||
restrict(&path)?;
|
||||
|
||||
let accounts_payload = accounts_payload(accounts);
|
||||
let path = paths.accounts_file();
|
||||
write_atomic(&path, accounts_payload.as_bytes())
|
||||
.map_err(|error| AuthError::store(path.display(), error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn credentials_payload(
|
||||
accounts: &[AccountMeta],
|
||||
credentials: &BTreeMap<String, Credentials>,
|
||||
) -> String {
|
||||
let mut entries = Vec::new();
|
||||
for meta in accounts {
|
||||
// 孤儿凭据(元数据已删)不写回。
|
||||
let Some(data) = credentials.get(meta.id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
entries.push(json!({
|
||||
"accountId": meta.id.as_str(),
|
||||
"data": data,
|
||||
}));
|
||||
}
|
||||
let payload = json!({
|
||||
"schemaVersion": AUTH_SCHEMA_VERSION,
|
||||
"protection": PROTECTION_PLAIN_V1,
|
||||
"entries": entries,
|
||||
});
|
||||
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn accounts_payload(accounts: &[AccountMeta]) -> String {
|
||||
let payload = json!({
|
||||
"schemaVersion": AUTH_SCHEMA_VERSION,
|
||||
"accounts": accounts,
|
||||
});
|
||||
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
/// 读元数据(坏记录跳过并记 warning)。
|
||||
pub(super) fn read_accounts(path: &Path, warnings: &mut Vec<String>) -> Vec<AccountMeta> {
|
||||
let mut accounts = Vec::new();
|
||||
let Some(text) = read_to_string(path, warnings) else {
|
||||
return accounts;
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<Value>(&text) else {
|
||||
warnings.push(format!("{} 不是合法 JSON,按空账户库处理", path.display()));
|
||||
return accounts;
|
||||
};
|
||||
let Some(items) = value.get("accounts").and_then(Value::as_array) else {
|
||||
warnings.push(format!("{} 缺 accounts 数组", path.display()));
|
||||
return accounts;
|
||||
};
|
||||
for item in items {
|
||||
match AccountMeta::from_value(item) {
|
||||
Ok(meta) => accounts.push(meta),
|
||||
Err(error) => warnings.push(format!("跳过一条账户元数据:{}", error.message())),
|
||||
}
|
||||
}
|
||||
accounts
|
||||
}
|
||||
|
||||
/// 读凭据(只保留 `known` 里存在的账户,孤儿丢弃并记 warning)。
|
||||
pub(super) fn read_credentials(
|
||||
path: &Path,
|
||||
known: &[String],
|
||||
warnings: &mut Vec<String>,
|
||||
) -> BTreeMap<String, Credentials> {
|
||||
let mut credentials = BTreeMap::new();
|
||||
let Some(text) = read_to_string(path, warnings) else {
|
||||
return credentials;
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<Value>(&text) else {
|
||||
warnings.push(format!(
|
||||
"{} 不是合法 JSON,按「无凭据」处理(账户列表仍可用)",
|
||||
path.display()
|
||||
));
|
||||
return credentials;
|
||||
};
|
||||
if let Some(protection) = value.get("protection").and_then(Value::as_str) {
|
||||
if protection != PROTECTION_PLAIN_V1 {
|
||||
warnings.push(format!(
|
||||
"{} 的保护级别是 `{protection}`,本期只认 `{PROTECTION_PLAIN_V1}`",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
let Some(entries) = value.get("entries").and_then(Value::as_array) else {
|
||||
return credentials;
|
||||
};
|
||||
for entry in entries {
|
||||
let Some(account_id) = entry.get("accountId").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let account_id = account_id.to_ascii_lowercase();
|
||||
if !known.contains(&account_id) {
|
||||
warnings.push(format!("凭据 {account_id} 没有对应账户,已忽略"));
|
||||
continue;
|
||||
}
|
||||
let Some(data) = entry.get("data") else {
|
||||
continue;
|
||||
};
|
||||
match serde_json::from_value::<Credentials>(data.clone()) {
|
||||
Ok(parsed) => {
|
||||
credentials.insert(account_id, parsed);
|
||||
}
|
||||
Err(error) => warnings.push(format!("跳过一条凭据({account_id}):{error}")),
|
||||
}
|
||||
}
|
||||
credentials
|
||||
}
|
||||
|
||||
fn read_to_string(path: &Path, warnings: &mut Vec<String>) -> Option<String> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => Some(text),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => {
|
||||
warnings.push(format!("读 {} 失败:{error}", path.display()));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把凭据文件收紧到「仅本人可读写」。
|
||||
///
|
||||
/// POSIX 侧显式 `0600`;Windows 侧 `set_permissions` 只能表达只读位,凭据保护依赖
|
||||
/// 用户目录默认 ACL(`data/auth/` 位于用户数据根下)。**这是已知缺口**:
|
||||
/// 补一个 `platform::atomic::write_atomic_private` 比在 auth 里散写 cfg 更对,
|
||||
/// 已作为 finding 报给 tower(见完成报告)。
|
||||
fn restrict(path: &Path) -> AuthResult<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let permissions = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(path, permissions)
|
||||
.map_err(|error| AuthError::store(path.display(), error.to_string()))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = path;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{accounts_payload, credentials_payload};
|
||||
use crate::auth::account::{AccountKind, AccountMeta, Credentials};
|
||||
use crate::auth::microsoft::MicrosoftSession;
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
fn microsoft_meta() -> (AccountMeta, Credentials) {
|
||||
let meta = AccountMeta::new(
|
||||
AccountKind::Microsoft,
|
||||
"0f8fad5bd9cb469fa16570867728950e",
|
||||
"Steve",
|
||||
7,
|
||||
);
|
||||
let session = MicrosoftSession::new(
|
||||
"0f8fad5bd9cb469fa16570867728950e",
|
||||
"Steve",
|
||||
Secret::new("mc-access-token"),
|
||||
Secret::new("live-refresh-token"),
|
||||
3600,
|
||||
1_000,
|
||||
None,
|
||||
);
|
||||
(meta, Credentials::Microsoft(session))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_payload_never_contains_tokens() {
|
||||
let (meta, creds) = microsoft_meta();
|
||||
let payload = accounts_payload(std::slice::from_ref(&meta));
|
||||
assert!(payload.contains("0f8fad5bd9cb469fa16570867728950e"));
|
||||
assert!(payload.contains("\"kind\": \"microsoft\""));
|
||||
assert!(!payload.contains("mc-access-token"));
|
||||
assert!(!payload.contains("live-refresh-token"));
|
||||
// 凭据对象的 Debug 也是脱敏的,别把 token 带进 payload。
|
||||
assert!(!format!("{creds:?}").contains("mc-access-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_payload_carries_protection_and_entries() {
|
||||
let (meta, creds) = microsoft_meta();
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert(meta.id.as_str().to_string(), creds);
|
||||
let payload = credentials_payload(std::slice::from_ref(&meta), &map);
|
||||
assert!(payload.contains("\"protection\": \"plain-v1\""));
|
||||
assert!(payload.contains(meta.id.as_str()));
|
||||
assert!(payload.contains("mc-access-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_credentials_are_not_written() {
|
||||
let (meta, creds) = microsoft_meta();
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert(
|
||||
"account:deadbeefdeadbeefdeadbeefdeadbeef".to_string(),
|
||||
creds,
|
||||
);
|
||||
let payload = credentials_payload(std::slice::from_ref(&meta), &map);
|
||||
assert!(!payload.contains("mc-access-token"));
|
||||
assert!(payload.contains("\"entries\": []"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user