Files
Unlucky-Launcher/crates/core/src/auth/account.rs
T
AskaEth 8194e38bcf feat(core): 加 MSA 授权码流(本地回调服务器)并接到账户页
research/08 方案 A:GUI 的微软登录改走授权码流(HMCL 主流做法),设备码流保留给
CLI/无浏览器场景。两条流只差「怎么拿到 MS access_token」,拿到之后共用
chain::complete_chain 与 MicrosoftSession,落盘/刷新语义没有第二套。

core(crates/core/src/auth/microsoft/)
- endpoints:新增 authorize(…/consumers/oauth2/v2.0/authorize),与 token 同租户。
- authcode:RedirectUri 是 http://localhost:{port}/auth 的唯一来源(授权 URL、
  token 请求体、实际监听三处共用,坑 5);AuthCodePolicy(回调超时 300s / 绑定重试 3 次 /
  固定端口 / 固定 state);authorization_url(response_type=code + prompt=select_account +
  scope=XboxLive.signin%20offline_access);exchange_code 复用 flow 的 oauth_error/tokens_from。
- callback:本地 HTTP/1.1 回调服务器。内核分配端口(占用按次数退避重试);非 /auth 路径
  (favicon、预连)404 后继续等;state 不匹配 400 后继续等;error=access_denied 归 Canceled;
  单条连接读头超时 5s,半开连接拖不垮时间预算。
- MicrosoftAuth::authcode_login(present):绑端口 → present(宿主开浏览器)→ 等回调 →
  换 token → 整链;失败与设备码流一样落 M38 失败日志。

app(crates/app)
- AppCommand::StartBrowserLogin + host::accounts::start_browser_login;设备码/授权码共用
  LoginJob 取消槽位、收尾入库(store_session)与面板 DTO。
- host::opener 用系统默认浏览器打开授权 URL(Windows rundll32 url.dll,FileProtocolHandler)。
- 账户页:主按钮「微软登录」走授权码流,「设备码登录」作备用;DeviceLoginView::browser_waiting
  在面板里给出可复制的授权链接(user_code 留空)。

测试
- crates/core/tests/auth/microsoft_authcode.rs:授权 URL 向量(租户/编码/prompt)、
  URI 一致性(授权 URL 与 token 表单同字面量)、mock 全链、favicon+陈旧 state 后仍拿到 code、
  端口占用重试文案、超时、access_denied→Canceled、login_with_xbox 403 的 M38 诊断
  (detail_code=login_with_xbox_403,真机白名单未批时验收止步于此)。
- 单测:authcode 编码/向量、callback query 解码与错误分流、DTO browser_waiting、conv 面板。

未做/未验证:真机完整登录需 Azure 公共客户端 id(ULL_MSA_CLIENT_ID)与 Minecraft API 许可,
本机只能到「授权页 302 到 login.live.com 且 redirect_uri/scope/prompt 原样保留」这一步。

AskaEth <i@yeij.top>
2026-11-01 08:33:11 +08:00

340 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 账户模型:**条目 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());
}
}