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>
This commit is contained in:
1 parent
f62bb00494
commit
8194e38bcf
22 files changed
+1188
-80
No files matched your search
@@ -0,0 +1,336 @@
|
||||
//! 授权码流的本地回调服务器(`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 后继续等**:旧标签页的回调不该打断新的登录;
|
||||
//! 真正的超时由调用方的时间预算兜底。
|
||||
|
||||
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)),
|
||||
};
|
||||
// 一条连接最多读这么久请求头:慢客户端/半开连接不能把整个登录拖过时间预算。
|
||||
let budget = REQUEST_READ_TIMEOUT.min(remaining);
|
||||
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);
|
||||
if let Some(error) = params.get("error") {
|
||||
respond(stream, 200, DENIED_PAGE).await;
|
||||
return Err(oauth_error(error, params.get("error_description")));
|
||||
}
|
||||
if params.get("state") != Some(self.state.as_str()) {
|
||||
respond(stream, 400, BAD_REQUEST_PAGE).await;
|
||||
return Ok(None);
|
||||
}
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user