Files
Unlucky-Launcher/crates/core/src/auth/microsoft/authcode.rs
T
AskaEth 07fd6113a7 fix(core): 回调先校验 state 再处理 error,堵掉伪造取消失效登录
reviewer P2(crates/core/src/auth/microsoft/callback.rs):handle() 先处理 error 参数、
之后才校验 state,于是任意一次 `GET http://localhost:{port}/auth?error=access_denied`
(state 随便给甚至不给)都会让 wait_for_code 直接返回 Canceled,登录被静默中止、账户不入库
—— 不需要知道随机 state、不需要 code、不需要任何凭据。违反 RFC 6749 §4.1.2.1 / RFC 9207
(错误响应同样要校验 state),也与本模块「与本次登录无关的请求一律继续等待」的声明矛盾。

- state 校验提到 error 之前:state 不匹配的请求(无论带不带 error)一律 400 后继续等;
  只有带**本机 state** 的 access_denied 才判 Canceled。模块文档第 4 条同步改写。
- 顺带采纳 reviewer 的两条非计数观察:
  * 读头预算改为 accept 之后按 deadline 重算(原来复用 accept 前的 remaining,
    总时长可超发 ≤5s);预算归零直接超时。
  * state 由 64 位升到 128 位(两个独立随机种子的 SipHash 输出拼接),对齐
    RFC 6749 §10.10 的熵建议;新增单测断言 32 位 hex 且每次不同。
- 新增回归用例(crates/core/tests/auth/microsoft_authcode.rs):
  `foreign_state_cannot_cancel_the_login` —— 先发 `?error=access_denied`(无 state)
  再发 `?error=access_denied&state=not-our-state`,两次都必须 400 且服务器继续等待,
  随后真回调拿到 code;`access_denied_with_our_state_is_still_canceled` 钉住另一半语义。
  既有的「陈旧 state 回 400 后继续等」用例保持通过。

门禁:cargo fmt --all --check OK;cargo clippy --workspace --all-targets -- -D warnings
exit 0 无告警;cargo test -p unlucky-core -p unlucky-app 18 个 suite 全绿(auth 59 例)。

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

285 lines
10 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.
//! 授权码流(`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 必须不同");
}
}