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>
271 lines
8.8 KiB
Rust
271 lines
8.8 KiB
Rust
//! 账户页 DTO:三条轨道的统一抽象 + 微软登录(设备码 / 授权码浏览器)的可见状态。
|
||
//!
|
||
//! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。
|
||
//! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层——
|
||
//! host 侧在拿到账户快照的那一刻就丢掉凭据,只留「能显示什么」。
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// 账户轨道(三轨统一抽象,`09 §G6` / `D9`)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum AccountKind {
|
||
/// 离线账户(HMCL 兼容 UUID)
|
||
Offline,
|
||
/// 微软正版
|
||
Microsoft,
|
||
/// authlib-injector(第三方皮肤站)
|
||
Authlib,
|
||
}
|
||
|
||
impl AccountKind {
|
||
/// 徽标文字。
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Offline => "离线",
|
||
Self::Microsoft => "微软",
|
||
Self::Authlib => "第三方",
|
||
}
|
||
}
|
||
|
||
/// 稳定 id(事件/持久化用;与 core 的 `kind.as_str()` 同词表)。
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::Offline => "offline",
|
||
Self::Microsoft => "microsoft",
|
||
Self::Authlib => "authlib_injector",
|
||
}
|
||
}
|
||
|
||
/// 从 core 的稳定 id 解析(未知轨道按离线处理,不让一个坏条目毁掉整个列表)。
|
||
pub fn parse(raw: &str) -> Self {
|
||
match raw {
|
||
"microsoft" => Self::Microsoft,
|
||
"authlib_injector" => Self::Authlib,
|
||
_ => Self::Offline,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 账户列表行(**只含可公开字段**)。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct AccountView {
|
||
/// 稳定 id(`account:...`)
|
||
pub id: String,
|
||
/// 展示名
|
||
pub name: String,
|
||
/// 轨道
|
||
pub kind: AccountKind,
|
||
/// 档案 UUID(公开)
|
||
pub profile_id: String,
|
||
/// 登录名(微软轨才有;可空)
|
||
pub login_name: String,
|
||
/// 创建时间展示串(`2026-10-31 20:12`)
|
||
pub created: String,
|
||
/// 是否是当前启动使用的账户
|
||
pub active: bool,
|
||
}
|
||
|
||
/// 微软登录阶段(UI 据此切换面板;两条流共用)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum DeviceLoginPhase {
|
||
/// 没有登录在进行
|
||
Idle,
|
||
/// 正在向微软申请设备码(短暂)
|
||
Requesting,
|
||
/// 已拿到用户码,等待用户在浏览器完成
|
||
Waiting,
|
||
/// 成功(账户已入库)
|
||
Succeeded,
|
||
/// 失败(原因在 `message`,已脱敏)
|
||
Failed,
|
||
}
|
||
|
||
impl DeviceLoginPhase {
|
||
/// 面板是否要显示(非 Idle 都显示)。
|
||
pub fn visible(self) -> bool {
|
||
!matches!(self, Self::Idle)
|
||
}
|
||
|
||
/// 是否还在跑(Requesting / Waiting)。
|
||
pub fn busy(self) -> bool {
|
||
matches!(self, Self::Requesting | Self::Waiting)
|
||
}
|
||
}
|
||
|
||
/// 设备码登录的可见状态。
|
||
///
|
||
/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`——
|
||
/// 它们本来就是给用户看的,不是凭据。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct DeviceLoginView {
|
||
/// 阶段
|
||
pub phase: DeviceLoginPhase,
|
||
/// 用户码(`ABCD-EFGH`)
|
||
pub user_code: String,
|
||
/// 验证地址(`https://www.microsoft.com/link`)
|
||
pub verification_uri: String,
|
||
/// 二维码内容(= 带码的链接;GUI 用文本展示,终端可直接打开)
|
||
pub qr_link: String,
|
||
/// 有效期(秒;0 = 未知)
|
||
pub expires_in: u64,
|
||
/// 状态/错误文案
|
||
pub message: String,
|
||
/// 成功后入库的账户展示名
|
||
pub account_name: String,
|
||
}
|
||
|
||
impl Default for DeviceLoginView {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl DeviceLoginView {
|
||
/// 空闲态。
|
||
pub fn new() -> Self {
|
||
Self {
|
||
phase: DeviceLoginPhase::Idle,
|
||
user_code: String::new(),
|
||
verification_uri: String::new(),
|
||
qr_link: String::new(),
|
||
expires_in: 0,
|
||
message: String::new(),
|
||
account_name: String::new(),
|
||
}
|
||
}
|
||
|
||
/// 已拿到用户码(`present` 回调时刻)。
|
||
pub fn waiting(
|
||
user_code: &str,
|
||
verification_uri: &str,
|
||
expires_in: u64,
|
||
qr_hint: &str,
|
||
) -> Self {
|
||
let qr_link = if qr_hint.is_empty() {
|
||
format!("{verification_uri}?code={user_code}")
|
||
} else {
|
||
qr_hint.to_string()
|
||
};
|
||
Self {
|
||
phase: DeviceLoginPhase::Waiting,
|
||
user_code: user_code.to_string(),
|
||
verification_uri: verification_uri.to_string(),
|
||
qr_link,
|
||
expires_in,
|
||
message: format!(
|
||
"在浏览器打开 {verification_uri} 并输入用户码 {user_code}({} 秒内有效)",
|
||
expires_in
|
||
),
|
||
account_name: String::new(),
|
||
}
|
||
}
|
||
|
||
/// 授权码流:本地回调已就绪、浏览器已拉起(`present` 回调时刻)。
|
||
///
|
||
/// 面板不展示用户码,改展示**可复制的授权链接**(用户可能需要在另一台设备/
|
||
/// 另一个浏览器里手动打开);`warning` 是「浏览器没拉起来」这类非致命提示。
|
||
pub fn browser_waiting(
|
||
authorization_url: &str,
|
||
expires_in: u64,
|
||
warning: Option<&str>,
|
||
) -> Self {
|
||
let mut message = format!(
|
||
"已在系统浏览器打开微软登录页,请完成授权({} 秒内有效);没弹出就把下面的链接粘到浏览器里",
|
||
expires_in.max(1)
|
||
);
|
||
if let Some(warning) = warning {
|
||
message.push_str(&format!("({warning})"));
|
||
}
|
||
Self {
|
||
phase: DeviceLoginPhase::Waiting,
|
||
user_code: String::new(),
|
||
verification_uri: authorization_url.to_string(),
|
||
qr_link: authorization_url.to_string(),
|
||
expires_in,
|
||
message,
|
||
account_name: String::new(),
|
||
}
|
||
}
|
||
|
||
/// 失败态。
|
||
pub fn failed(message: impl Into<String>) -> Self {
|
||
Self {
|
||
phase: DeviceLoginPhase::Failed,
|
||
message: message.into(),
|
||
..Self::new()
|
||
}
|
||
}
|
||
|
||
/// 成功态。
|
||
pub fn succeeded(account_name: impl Into<String>) -> Self {
|
||
let account_name = account_name.into();
|
||
Self {
|
||
phase: DeviceLoginPhase::Succeeded,
|
||
message: format!("已登录:{account_name}"),
|
||
account_name,
|
||
..Self::new()
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn kind_round_trips_through_stable_ids() {
|
||
for kind in [
|
||
AccountKind::Offline,
|
||
AccountKind::Microsoft,
|
||
AccountKind::Authlib,
|
||
] {
|
||
assert_eq!(AccountKind::parse(kind.as_str()), kind);
|
||
assert!(!kind.label().is_empty());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn device_login_view_never_carries_tokens() {
|
||
let view = DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
|
||
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
|
||
assert!(view.phase.busy());
|
||
assert!(view.qr_link.contains("ABCD-EFGH"));
|
||
let json = serde_json::to_string(&view).expect("序列化");
|
||
for forbidden in ["token", "secret", "password", "refresh"] {
|
||
assert!(
|
||
!json.to_lowercase().contains(forbidden),
|
||
"视图里不该出现 {forbidden}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn browser_waiting_shows_a_copyable_link_instead_of_a_user_code() {
|
||
let view = DeviceLoginView::browser_waiting(
|
||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
|
||
300,
|
||
Some("拉起浏览器失败"),
|
||
);
|
||
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
|
||
assert!(view.phase.busy());
|
||
assert!(view.user_code.is_empty(), "授权码流没有用户码");
|
||
assert_eq!(view.qr_link, view.verification_uri);
|
||
assert!(view.message.contains("浏览器"));
|
||
assert!(view.message.contains("拉起浏览器失败"), "{}", view.message);
|
||
let json = serde_json::to_string(&view).expect("序列化");
|
||
for forbidden in ["refresh", "mc-access"] {
|
||
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn succeeded_and_failed_clear_the_code() {
|
||
let ok = DeviceLoginView::succeeded("Steve");
|
||
assert_eq!(ok.phase, DeviceLoginPhase::Succeeded);
|
||
assert!(ok.user_code.is_empty());
|
||
let bad = DeviceLoginView::failed("网络超时");
|
||
assert_eq!(bad.phase, DeviceLoginPhase::Failed);
|
||
assert_eq!(bad.message, "网络超时");
|
||
}
|
||
}
|