diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d967f5..f04b75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes are recorded here. Versions follow [SemVer](https://semver.org/). +## Unreleased + +**schema 17 → 18**;新增可选 `[github]` 段(`client_id` / `client_secret`,留空 = 未启用,端点答 503「未启用」而非 500);**GitHub 登录**(设备码流程 + 网页登录页「使用 GitHub 登录」+ 身份即账号,rant 2026-11-09T14:43:55);`cargo test` 590 → 605。 + ## v0.7.33 (2026-10-15) 自 v0.7.32 起累计 1 个提交(#409)。**无 schema 变更(仍 16)**;**无行为变更** —— 唯一的改动是两条保留门禁的模块注释(`tx_retention_gate.rs` 与 `usage_retention_gate.rs`)。 diff --git a/config/config.example.toml b/config/config.example.toml index a15f999..5cc0c40 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -90,6 +90,27 @@ enabled = true # 是否启用(默认 true) batch = 2_000 # 每轮最多删多少行(越小 → 单次持库时间越短) interval_secs = 60 # 扫描间隔(秒) +# ============================================================ +# 0.6 GitHub 登录(rant 2026-11-09T14:43:55):设备码流程(桌面端 / CLI / agent) +# 与网页「使用 GitHub 登录」共用同一套身份 —— 首次登录即注册,之后同一 GitHub +# 账号永远回到同一账号。 +# 配置项**只有两个**:OAuth App 的 client id / secret。**留空 = 未启用**, +# 此时 /api/auth/github/* 返回 503「未启用」而不是 500。 +# ⚠️ 该 OAuth App **必须开启 Device Flow**:GitHub → Settings → Developer settings +# → OAuth Apps → 该应用 → 勾选 "Enable Device Flow"。未开启时设备码端点答 +# device_flow_disabled(网页授权码流程不受影响)。 +# scope 不请求 user:email —— 邮箱一律用 GitHub 公布的 noreply 地址 +# +@users.noreply.github.com,故拿不到公开邮箱也能注册成功。 +# ============================================================ +[github] +# 配置项只有这两个;留空 = GitHub 登录未启用(端点答 503「未启用」,不是 500)。 +# OAuth App 里要开两样东西,否则两条流程各有一半走不通: +# · 勾上 "Enable Device Flow" —— 否则设备码端点(桌面端 / CLI / agent)拿不到码; +# · Authorization callback URL 填 server.public_url 本身(本行下面的 8080 就是默认值), +# 网页登录页的「使用 GitHub 登录」按它回跳。 +client_id = "" # OAuth App 的 Client ID;留空 = GitHub 登录未启用 +client_secret = "" # OAuth App 的 Client Secret;与 client_id 成对出现 + # ============================================================ # 1. 点数规则(账本层的锚) # ============================================================ diff --git a/src/config.rs b/src/config.rs index 1bb68ff..6d0fec4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -340,12 +340,41 @@ pub struct Billing { pub holidays: Vec, } +/// GitHub 登录(rant 2026-11-09T14:43:55):OAuth App 的两个凭据,**仅此两项**。 +/// +/// 留空 = 未启用:`/api/auth/github/*` 两个端点返回 **503 + 明确说「未启用」**,而不是 +/// 500 —— 「没配」是部署状态,不是内部故障。 +/// +/// ⚠️ 该 OAuth App **必须开启 Device Flow**(GitHub → Settings → Developer settings → +/// OAuth Apps → 该应用 → 勾选 "Enable Device Flow")。未开启时 GitHub 对 +/// `POST /login/device/code` 一律答 `device_flow_disabled`,没有浏览器回调的客户端 +///(桌面端 / CLI / agent)就走不通;网页授权码流程不受此开关影响。 +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Github { + #[serde(default)] + pub client_id: String, + /// 设备码流程同样要 secret:GitHub 换取 access_token 时要求 client_secret 一并提交 + #[serde(default)] + pub client_secret: String, +} + +impl Github { + /// 两个凭据都在才算启用。只配一半按**未启用**处理:半份配置换不到 token, + /// 报「未启用」比让用户对着 github.com 的 400 猜要好。 + pub fn enabled(&self) -> bool { + !self.client_id.trim().is_empty() && !self.client_secret.trim().is_empty() + } +} + /// 顶层配置 #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { #[serde(default)] pub server: Server, + /// GitHub 登录(可选;客户端凭据留空 = 未启用) + #[serde(default)] + pub github: Github, /// 邮件服务(可选;未配置时注册验证码走 dev 日志模式) #[serde(default)] pub mail: Mail, diff --git a/src/dao.rs b/src/dao.rs index ed22100..da5567b 100644 --- a/src/dao.rs +++ b/src/dao.rs @@ -421,6 +421,57 @@ pub fn activate_user(conn: &Connection, email: &str) -> Result<()> { Ok(()) } +/// GitHub 的 noreply 邮箱:`+@users.noreply.github.com` +/// +/// 这是 GitHub **公布**的地址形式(寄到它会进该账号的收件箱),不是随手造的占位串; +/// 用它让 GitHub 账号照样满足 `users.email NOT NULL UNIQUE`,而**不必**向 GitHub 索取 +/// `user:email` 权限 —— 验收 3 要求「拿不到公开邮箱也必须注册成功」。 +pub fn github_noreply_email(github_id: &str, login: &str) -> String { + format!("{github_id}+{login}@users.noreply.github.com") +} + +/// GitHub 身份 → 本平台账号(rant 2026-11-09T14:43:55 验收 2):首次登录即注册。 +/// +/// 身份键是 `github_id`(GitHub 的稳定数字 id),**不是** login —— login 可以改名, +/// 拿它当键会在改名那天建出第二个账号。返回 `(user_id, 是否本次新建)`。 +/// +/// 新行的 `email` 取 [`github_noreply_email`](理由与「为什么加列而不重建表」见 +/// `db::migrate` 的 v18 段),`password_hash` 留空 ⇒ 这类账号不能用邮箱 + 口令登录。 +/// 新行 `verified = 1`:GitHub 身份本身就是已验证身份,不走邮箱验证码。 +/// +/// 登录改名时**只更新 `github_login`**,不重写 `email`:那个地址只是占位(没有读者依赖 +/// 它),而 `users.email` 有 UNIQUE 约束 ⇒ 跟着改名去 UPDATE 只是给自己加一条可能撞唯一键 +/// 的写入。 +pub fn upsert_github_user(conn: &Connection, github_id: &str, login: &str) -> Result<(i64, bool)> { + let existing: Option = conn + .query_row( + "SELECT id FROM users WHERE github_id = ?1", + [github_id], + |r| r.get(0), + ) + .ok(); + if let Some(id) = existing { + conn.execute( + "UPDATE users SET github_login = ?1 WHERE id = ?2 AND github_login <> ?1", + rusqlite::params![login, id], + )?; + return Ok((id, false)); + } + let email = github_noreply_email(github_id, login); + conn.execute( + "INSERT INTO users (email, password_hash, name, role, verified, github_id, github_login) \ + VALUES (?1, '', ?2, 'user', 1, ?3, ?4)", + rusqlite::params![email, login, github_id, login], + )?; + let id = conn.last_insert_rowid(); + // 可用余额账户随建号创建(验收 4)——与邮箱注册在 verify 里做的是同一件事 + conn.execute( + "INSERT OR IGNORE INTO quotas (user_id, balance, gift_balance) VALUES (?1, 0, 0)", + [id], + )?; + Ok((id, true)) +} + /// 存储验证码(6 位数字的 sha256 hex);同一邮箱重复注册/重发 → 覆盖旧码。 /// 过期时间由 SQL 自身计算(`datetime('now', '+10 minutes')`):`find_valid_verification` 的谓词是 /// `expires_at > datetime('now')`,写入方必须写入**该谓词能解释**的值——把字面量 `'+10 minutes'` diff --git a/src/db.rs b/src/db.rs index 266b1ea..32d7ca8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result}; use rusqlite::Connection; use std::collections::HashMap; -pub const SCHEMA_VERSION: i64 = 17; +pub const SCHEMA_VERSION: i64 = 18; /// 打开(或创建)数据库并执行幂等迁移(生产标准:空库只建表,不种任何假数据) pub fn open(path: &str) -> Result { @@ -446,6 +446,33 @@ pub fn migrate(conn: &Connection) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_transactions_rollup_type_bucket ON transactions_rollup(type, bucket);", )?; + // v18(rant 2026-11-09T14:43:55):GitHub 身份即账号 —— `users` 补两列 + 一个部分唯一索引。 + // + // 为什么是「加列」而不是重建表:`email` / `password_hash` 是 NOT NULL,重建能让它们可空, + // 但那样全仓**每一个** `SELECT ... email FROM users` 的读取点都得改成容忍 NULL(管理端 + // 用户列表、运营报表…),一处迁移会扩散成跨模块重写。GitHub 账号因此仍然写一个 `email`, + // 取值是 GitHub **公布**的 noreply 地址 `+@users.noreply.github.com`(寄到它会进 + // 该账号收件箱,不是编造的占位串),口令列写 '' —— `auth::verify_password` 对空 PHC 串恒 + // 返回 false,故这类账号不可能用邮箱 + 口令登录。 + // + // 身份认 `github_id`(GitHub 的稳定数字 id),**不认 login**:login 可以改名,拿它当键会在 + // 改名那天建出第二个账号。部分唯一索引把「非 GitHub 账号」排除在外(它们的值是 '')。 + ensure_column( + conn, + "users", + "github_id", + "github_id TEXT NOT NULL DEFAULT ''", + )?; + ensure_column( + conn, + "users", + "github_login", + "github_login TEXT NOT NULL DEFAULT ''", + )?; + conn.execute_batch( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_github_id + ON users(github_id) WHERE github_id <> '';", + )?; // schema_version 记录**最高的**已迁移版本。⚠️ 必须用 MAX 读:该表没有唯一约束, // 而 `INSERT OR REPLACE` 在无冲突时就是普通 INSERT —— 只读第一行的话 // `v < SCHEMA_VERSION` 会永远为真,每次启动都追加一行(`atp-data/aitokenpool.db` diff --git a/src/github_oauth_gate.rs b/src/github_oauth_gate.rs new file mode 100644 index 0000000..2e21911 --- /dev/null +++ b/src/github_oauth_gate.rs @@ -0,0 +1,741 @@ +//! GitHub 登录门禁(rant 2026-11-09T14:43:55 验收 1) +//! +//! 只读语料,零生产代码: +//! - `src/routes/mod.rs` —— 两个端点必须登记在契约路径下; +//! - `src/routes/github_oauth.rs` —— 五个 / 三个字段名、四种中间态与状态码的映射; +//! - `config/config.example.toml` —— 配置项只有两个(client id / secret),且必须写明 +//! OAuth App 要开 Device Flow。 +//! +//! 另有四条**行为**断言(同一份文件,走本地假上游,不打真实 github.com —— 验收 2): +//! 中间态绝不发 key、同一身份两次登录复用同一个 key 且各表只增一行、新账号有可用余额账户 +//! 且当日赠送可发放、未配置凭据返回「未启用」而不是 500。 +//! +//! ⚠️ 每条断言都带**非空阳性对照**:提取器一旦失真(比如路径或字段名后来改了),断言 +//! 会在空集上「通过」—— 阳性对照让这种情况变成红灯,而不是静默的绿。 + +/// 本文件整体只在测试期编译(`main.rs` 里 `#[cfg(test)] mod github_oauth_gate;`)。 +/// 下面这个 `#[cfg(test)]` 是**给 i18n 提取器看的**:它在第一个 `#[cfg(test)]` 处截断, +/// 于是本文件里那些 `{"error": …}` 夹具不会被当成「后端错误文案产出者」(与 `src/` 顶层 +/// 其它 gate 模块同一个形状,见 `i18n_pack::backend_error_sources_cover_every_file_that_emits_an_error_literal`)。 +#[cfg(test)] +mod gate { + use axum::extract::State as AxState; + use axum::http::StatusCode; + use axum::routing::post; + use axum::Json; + use axum::Router; + use serde_json::{json, Value}; + use std::sync::{Arc, Mutex}; + use tower::util::ServiceExt; + + use crate::routes::AppState; + + const ROUTES_SRC: &str = include_str!("routes/mod.rs"); + const GH_SRC: &str = include_str!("routes/github_oauth.rs"); + const EXAMPLE_CFG: &str = include_str!("../config/config.example.toml"); + + // ============================================================ + // 语料侧:契约的形状 + // ============================================================ + + /// R1:两个端点登记在契约路径下 + #[test] + fn the_two_endpoints_are_registered_under_the_contract_paths() { + // 阳性对照:同一份语料里必须找得到一个**已知存在**的路由。提取器失真时 + // (比如 ROUTES_SRC 读到空串、或路径写法整体换掉)这条先红。 + assert!( + ROUTES_SRC.contains(r#".route("/api/auth/login", post(login))"#), + "阳性对照失效:语料里连邮箱登录路由都没有,提取器不可信" + ); + for path in [ + "/api/auth/github/device/code", + "/api/auth/github/device/token", + // 网页登录页的那一半(验收 5) + "/api/auth/github/authorize", + "/api/auth/github/exchange", + ] { + // 只认路径字面量本身:路由写成一行还是折行不是契约,挂了没有才是 + assert!( + ROUTES_SRC.contains(&format!("\"{path}\"")), + "路由 {path} 未登记" + ); + } + // 处理函数也必须接在路由上(路径在、函数没接 = 路由表与实现脱节) + for handler in ["device_code", "device_token", "authorize", "exchange"] { + assert!( + ROUTES_SRC.contains(&format!("github_oauth::{handler}")), + "处理函数 {handler} 未接到路由上" + ); + } + } + + /// R2:设备码响应的五个字段逐一在场 + #[test] + fn the_device_code_response_carries_the_five_contract_fields() { + // 阳性对照:一个必然在场的字段名 + assert!(GH_SRC.contains("\"device_code\":")); + for field in [ + "device_code", + "user_code", + "verification_uri", + "expires_in", + "interval", + ] { + let needle = format!("\"{field}\":"); + assert!(GH_SRC.contains(&needle), "设备码响应缺字段 {field}"); + } + } + + /// R3:换取响应的三个字段逐一在场,且凭据字段与邮箱登录同形 + #[test] + fn the_token_response_carries_the_contract_fields() { + assert!( + GH_SRC.contains("\"api_key\":"), + "阳性对照:api_key 必须在场" + ); + for field in ["api_key", "user_id", "login"] { + let needle = format!("\"{field}\":"); + assert!(GH_SRC.contains(&needle), "换取响应缺字段 {field}"); + } + } + + /// R4:四种中间态各自映射到契约状态码,且**每一种**都先于 access_token 判定 + #[test] + fn the_four_intermediate_states_map_to_their_contract_codes() { + // 阳性对照:四种状态名里至少一个必须在场 + assert!(GH_SRC.contains("authorization_pending")); + let pairs = [ + ("authorization_pending", "StatusCode::ACCEPTED"), + ("slow_down", "StatusCode::TOO_MANY_REQUESTS"), + ("expired_token", "StatusCode::GONE"), + ("access_denied", "StatusCode::FORBIDDEN"), + ]; + // 去掉全部空白再匹配:臂怎么折行不是契约,**码与状态码配在一起**才是 + let flat: String = GH_SRC.chars().filter(|c| !c.is_whitespace()).collect(); + for (err, status) in pairs { + let needle = format!("\"{err}\"=>({status}"); + assert!( + flat.contains(&needle), + "中间态 {err} 必须映射到 {status}(契约状态码,客户端已按此实现)" + ); + } + } + + /// R5:示例配置只声明两个键,并写明 OAuth App 必须开 Device Flow + #[test] + fn the_example_config_declares_only_the_two_credentials() { + let start = EXAMPLE_CFG + .find("\n[github]\n") + .expect("示例配置必须出现 [github] 段(否则用户不知道有这个开关)") + + 1; + let rest = &EXAMPLE_CFG[start..]; + let end = rest.find("\n[").unwrap_or(rest.len()); + let block = &rest[..end]; + let mut keys: Vec<&str> = block + .lines() + .filter(|l| !l.trim_start().starts_with('#') && l.contains('=')) + .filter_map(|l| l.split('=').next()) + .map(str::trim) + .collect(); + keys.sort_unstable(); + assert_eq!( + keys, + vec!["client_id", "client_secret"], + "配置项只有两个(client id / secret),不得扩键" + ); + assert!( + EXAMPLE_CFG.contains("Enable Device Flow"), + "示例配置必须写明 OAuth App 要开 Device Flow,否则设备码流程静默不可用" + ); + } + + // ============================================================ + // 行为侧:本地假上游(不打真实 github.com) + // ============================================================ + + #[derive(Clone)] + struct Mock { + /// `/login/oauth/access_token` 依次给出的响应;用完后重复最后一个 + tokens: Arc>>, + /// `GET /user` 的响应 + user: Value, + /// 被打到的路径(证明请求确实进了假上游,而不是在别处「通过」) + seen: Arc>>, + } + + async fn mock_device_code(AxState(m): AxState) -> Json { + m.seen.lock().unwrap().push("device/code"); + Json(json!({ + "device_code": "dc-abc", + "user_code": "UC-123", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5 + })) + } + + async fn mock_token(AxState(m): AxState) -> Json { + let queue = m.tokens.lock().unwrap(); + if queue.is_empty() { + return Json(json!({})); + } + let at = if queue.len() > 1 { 0 } else { queue.len() - 1 }; + Json(queue[at].clone()) + } + + async fn mock_user(AxState(m): AxState) -> Json { + m.seen.lock().unwrap().push("user"); + Json(m.user.clone()) + } + + /// 起一个本地假 GitHub,返回它的 base url 与可观测句柄 + async fn start_mock(tokens: Vec, user: Value) -> (String, Mock) { + let mock = Mock { + tokens: Arc::new(Mutex::new(tokens)), + user, + seen: Arc::new(Mutex::new(Vec::new())), + }; + let app = Router::new() + .route("/login/device/code", post(mock_device_code)) + .route("/login/oauth/access_token", post(mock_token)) + .route("/user", axum::routing::get(mock_user)) + .with_state(mock.clone()); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind 假上游"); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("假上游服务"); + }); + (base, mock) + } + + /// 建状态:`base = None` → 凭据留空(未启用);`Some` → 配好凭据并指向假上游。 + /// 不 seed 任何用户:断言「只增一行」要一个空基线。 + fn state_with(tag: &str, base: Option<&str>) -> AppState { + let p = std::env::temp_dir().join(format!("atp_gh_{}_{}.db", std::process::id(), tag)); + let _ = std::fs::remove_file(&p); + let conn = crate::db::open(p.to_str().unwrap()).expect("open tmp db"); + let mut cfg = crate::config::Config::load("config/config.example.toml").unwrap(); + match base { + Some(_) => { + cfg.github.client_id = "test-client".to_string(); + cfg.github.client_secret = "test-secret".to_string(); + } + None => { + cfg.github.client_id = String::new(); + cfg.github.client_secret = String::new(); + } + } + let crypto = crate::crypto::Crypto::new([7u8; 32]); + let mut st = AppState::new(conn, Arc::new(cfg), crypto); + if let Some(b) = base { + st.github_api = crate::routes::github_oauth::GithubEndpoints { + oauth_base: b.to_string(), + api_base: b.to_string(), + }; + } + st + } + + async fn post_json(st: AppState, uri: &str, body: &str) -> (StatusCode, Value) { + let req = axum::http::Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(axum::body::Body::from(body.to_string())) + .unwrap(); + let resp = crate::routes::router() + .with_state(st) + .oneshot(req) + .await + .unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) + .await + .unwrap(); + let v = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, v) + } + + fn count(st: &AppState, sql: &str) -> i64 { + let conn = st.db.lock().unwrap(); + conn.query_row(sql, [], |r| r.get(0)).unwrap() + } + + /// 未启用 → 503 + 「未启用」,**不是** 500;阳性对照:配好凭据后同一端点必须通 + #[tokio::test] + async fn an_unconfigured_client_answers_503_and_says_it_is_not_enabled() { + let st = state_with("disabled", None); + let (status, body) = post_json(st, "/api/auth/github/device/code", "").await; + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "未启用必须是有话可说的 503,而不是 500:{body}" + ); + let msg = body["error"].as_str().unwrap_or_default(); + assert_eq!( + msg, "github_login_not_enabled", + "未启用要有自己的机器码(客户端按码分支):{body}" + ); + let detail = body["detail"].as_str().unwrap_or_default(); + assert!(detail.contains("client_id"), "还要说清该配什么:{body}"); + + // 阳性对照:同一端点、只把凭据填上,就必须 200 + let (base, _m) = start_mock( + vec![json!({"access_token": "gho_x"})], + json!({"id": 1, "login": "a"}), + ) + .await; + let st2 = state_with("disabled_control", Some(&base)); + let (s2, b2) = post_json(st2, "/api/auth/github/device/code", "").await; + assert_eq!(s2, StatusCode::OK, "阳性对照:配好凭据后必须通:{b2}"); + } + + /// 200 + 五个字段逐一出值(不是 null) + #[tokio::test] + async fn the_device_code_response_fills_the_five_fields() { + let (base, mock) = start_mock( + vec![json!({"access_token": "gho_x"})], + json!({"id": 1, "login": "a"}), + ) + .await; + let st = state_with("code_fields", Some(&base)); + let (status, body) = post_json(st, "/api/auth/github/device/code", "").await; + assert_eq!(status, StatusCode::OK, "{body}"); + for field in [ + "device_code", + "user_code", + "verification_uri", + "expires_in", + "interval", + ] { + assert!(!body[field].is_null(), "字段 {field} 必须有值:{body}"); + } + assert_eq!( + *mock.seen.lock().unwrap(), + vec!["device/code"], + "请求必须真的进假上游" + ); + } + + /// 中间态绝不发 key:202 + 零 key 行、零用户行 + #[tokio::test] + async fn an_authorization_pending_never_issues_a_key() { + let (base, _m) = start_mock( + vec![json!({"error": "authorization_pending"})], + json!({"id": 7, "login": "octo"}), + ) + .await; + let st = state_with("pending", Some(&base)); + let (status, body) = post_json( + st.clone(), + "/api/auth/github/device/token", + r#"{"device_code":"dc-abc"}"#, + ) + .await; + assert_eq!(status, StatusCode::ACCEPTED, "pending 是 202:{body}"); + assert_eq!(body["error"], "authorization_pending"); + assert!(body.get("api_key").is_none(), "中间态绝不发 key:{body}"); + assert_eq!( + count(&st, "SELECT COUNT(*) FROM api_keys"), + 0, + "不许落 key 行" + ); + assert_eq!(count(&st, "SELECT COUNT(*) FROM users"), 0, "不许建用户行"); + } + + /// 其余三种中间态各自的契约状态码 + #[tokio::test] + async fn the_other_three_intermediate_states_map_to_429_410_403() { + for (err, want) in [ + ("slow_down", StatusCode::TOO_MANY_REQUESTS), + ("expired_token", StatusCode::GONE), + ("access_denied", StatusCode::FORBIDDEN), + ] { + let (base, _m) = start_mock( + vec![json!({"error": err})], + json!({"id": 9, "login": "octo"}), + ) + .await; + let st = state_with(&format!("mid_{err}"), Some(&base)); + let (status, body) = post_json( + st, + "/api/auth/github/device/token", + r#"{"device_code":"dc-abc"}"#, + ) + .await; + assert_eq!(status, want, "{err} 必须是 {want}:{body}"); + assert_eq!(body["error"], err); + } + } + + /// 同一 GitHub 身份两次登录:用户行与 key 行各只增一次,`api_key` 逐字相同 + #[tokio::test] + async fn the_same_identity_twice_returns_the_same_key_and_adds_one_row_each() { + // 假上游第一次给 token、第二次也给 token(同一个 GitHub 账号登录两次) + let (base, mock) = start_mock( + vec![json!({"access_token": "gho_x"})], + json!({"id": 4242, "login": "octocat"}), + ) + .await; + let st = state_with("same_twice", Some(&base)); + let (s1, b1) = post_json( + st.clone(), + "/api/auth/github/device/token", + r#"{"device_code":"dc-abc"}"#, + ) + .await; + assert_eq!(s1, StatusCode::OK, "{b1}"); + assert_eq!(b1["login"], "octocat", "响应要带 login:{b1}"); + let first_key = b1["api_key"] + .as_str() + .expect("api_key 是字符串") + .to_string(); + assert!( + first_key.starts_with("atk_live_"), + "凭据与邮箱登录同形(同一个签发函数):{first_key}" + ); + let first_user = b1["user_id"].clone(); + assert!(first_user.as_i64().is_some(), "user_id 是数字:{b1}"); + + let (s2, b2) = post_json( + st.clone(), + "/api/auth/github/device/token", + r#"{"device_code":"dc-abc"}"#, + ) + .await; + assert_eq!(s2, StatusCode::OK, "{b2}"); + assert_eq!(b2["api_key"], b1["api_key"], "同一身份必须复用同一个 key"); + assert_eq!(b2["user_id"], first_user, "同一身份必须回到同一账号"); + assert_eq!( + count(&st, "SELECT COUNT(*) FROM users"), + 1, + "用户行只增一次" + ); + assert_eq!( + count(&st, "SELECT COUNT(*) FROM api_keys"), + 1, + "key 行只增一次" + ); + assert!( + mock.seen.lock().unwrap().contains(&"user"), + "身份必须真的从假上游读回来" + ); + } + + /// 新账号:可用余额账户随建号创建,当日赠送可发放(与邮箱注册待遇一致) + #[tokio::test] + async fn a_new_account_has_a_quota_row_and_can_receive_the_daily_gift() { + let (base, _m) = start_mock( + vec![json!({"access_token": "gho_x"})], + json!({"id": 555, "login": "newbie"}), + ) + .await; + let st = state_with("new_account", Some(&base)); + let (status, body) = post_json( + st.clone(), + "/api/auth/github/device/token", + r#"{"device_code":"dc-abc"}"#, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let user_id = body["user_id"].as_i64().expect("user_id"); + + let conn = st.db.lock().unwrap(); + let quotas: i64 = conn + .query_row( + "SELECT COUNT(*) FROM quotas WHERE user_id = ?1", + [user_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(quotas, 1, "可用余额账户随建号创建"); + // 注册即已验证(验收 3):不走邮箱验证码 + let verified: i64 = conn + .query_row("SELECT verified FROM users WHERE id = ?1", [user_id], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(verified, 1, "GitHub 身份即已验证,不该落在待验证态"); + // 每日赠送照常发放(验收 4) + let granted = crate::gift::ensure_daily_gift(&conn, user_id).expect("赠送可发放"); + assert!(granted, "新账号当日赠送必须能发出来"); + let gift_balance: f64 = conn + .query_row( + "SELECT gift_balance FROM quotas WHERE user_id = ?1", + [user_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(gift_balance, 1.0, "每日赠送 1 点"); + } + + // ============================================================ + // 网页授权码流程(验收 5):登录页的「使用 GitHub 登录」 + // ============================================================ + + /// 带 cookie 发请求:网页流程的 state 就活在这张 cookie 里 + async fn req_with( + st: AppState, + method: &str, + uri: &str, + body: Option<&str>, + cookie: Option<&str>, + ) -> (StatusCode, axum::http::HeaderMap, Value) { + let mut b = axum::http::Request::builder().method(method).uri(uri); + if let Some(c) = cookie { + b = b.header("cookie", c); + } + let req = match body { + Some(j) => b + .header("content-type", "application/json") + .body(axum::body::Body::from(j.to_string())) + .unwrap(), + None => b.body(axum::body::Body::empty()).unwrap(), + }; + let resp = crate::routes::router() + .with_state(st) + .oneshot(req) + .await + .unwrap(); + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) + .await + .unwrap(); + let v = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, headers, v) + } + + fn set_cookie(headers: &axum::http::HeaderMap) -> String { + headers + .get(axum::http::header::SET_COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string() + } + + /// 授权地址由 `public_url` 派生(**不**新增第三个配置键),state 落一张 HttpOnly cookie; + /// 未启用时同样是 503 + #[tokio::test] + async fn the_authorize_endpoint_derives_its_redirect_from_public_url() { + let (base, _m) = start_mock( + vec![json!({"access_token": "gho_x"})], + json!({"id": 1, "login": "a"}), + ) + .await; + let st = state_with("authorize", Some(&base)); + let public_url = st.cfg.server.public_url.trim_end_matches('/').to_string(); + let (status, headers, body) = + req_with(st.clone(), "GET", "/api/auth/github/authorize", None, None).await; + assert_eq!(status, StatusCode::OK, "配好凭据后必须给出授权地址:{body}"); + let url = body["authorize_url"] + .as_str() + .unwrap_or_default() + .to_string(); + // 阳性对照:地址确实指向(可注入的)假上游,而不是一个常量串 + assert!( + url.starts_with(&format!("{base}/login/oauth/authorize")), + "必须打可注入的上游:{url}" + ); + assert!(url.contains("client_id=test-client"), "缺 client_id:{url}"); + let encoded = public_url.replace(':', "%3A").replace('/', "%2F"); + assert!( + url.contains(&format!("redirect_uri={encoded}")), + "redirect_uri 必须由 public_url 派生(期望 {encoded}):{url}" + ); + assert!(url.contains("state="), "缺 state:{url}"); + + let cookie = set_cookie(&headers); + assert!( + cookie.starts_with("atp_gh_state=") && !cookie.starts_with("atp_gh_state=;"), + "state 必须落一张非空 cookie:{cookie:?}" + ); + assert!( + cookie.contains("HttpOnly"), + "state cookie 不能被 JS 读到:{cookie:?}" + ); + assert!( + cookie.contains("SameSite=Lax"), + "state cookie 必须限制跨站:{cookie:?}" + ); + + // 未启用 → 503「未启用」而不是 500 + let off = state_with("authorize_off", None); + let (s2, _h2, b2) = req_with(off, "GET", "/api/auth/github/authorize", None, None).await; + assert_eq!( + s2, + StatusCode::SERVICE_UNAVAILABLE, + "未启用必须是 503:{b2}" + ); + } + + /// state 对不上 → 400,**不换码、不落库、不打上游** + #[tokio::test] + async fn a_state_that_does_not_match_its_cookie_buys_no_credentials() { + let (base, mock) = start_mock( + vec![json!({"access_token": "gho_x"})], + json!({"id": 5, "login": "octo"}), + ) + .await; + let st = state_with("state_mismatch", Some(&base)); + let (status, _h, body) = req_with( + st.clone(), + "POST", + "/api/auth/github/exchange", + Some(r#"{"code":"c1","state":"forged"}"#), + Some("atp_gh_state=real-nonce"), + ) + .await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "state 不匹配必须 400:{body}" + ); + assert_eq!(body["error"], "github_state_mismatch", "{body}"); + assert!(body.get("api_key").is_none(), "不许发 key:{body}"); + assert_eq!(count(&st, "SELECT COUNT(*) FROM users"), 0, "不许建用户行"); + assert_eq!( + count(&st, "SELECT COUNT(*) FROM api_keys"), + 0, + "不许落 key 行" + ); + assert!( + mock.seen.lock().unwrap().is_empty(), + "伪造的 state 不该消耗上游:{:?}", + mock.seen.lock().unwrap() + ); + + // 阳性对照:同一端点、只把 cookie 换成对得上的值,就必须拿到凭据 + let (s2, _h2, b2) = req_with( + st.clone(), + "POST", + "/api/auth/github/exchange", + Some(r#"{"code":"c1","state":"nonce-ok"}"#), + Some("atp_gh_state=nonce-ok"), + ) + .await; + assert_eq!(s2, StatusCode::OK, "阳性对照:对得上的 state 必须通:{b2}"); + assert!( + !b2["api_key"].as_str().unwrap_or_default().is_empty(), + "{b2}" + ); + } + + /// 网页流程与设备码流程落到**同一行用户、同一把 key**(验收 5 + 2) + #[tokio::test] + async fn the_web_flow_lands_the_same_account_and_key_as_the_device_flow() { + let (base, _m) = start_mock( + vec![json!({"access_token": "gho_x"})], + json!({"id": 42, "login": "octo"}), + ) + .await; + let st = state_with("web_flow", Some(&base)); + // 前端就是这么拿 state 的:先要授权地址,服务端同时把 state 写进 cookie + let (_s, headers, _b) = + req_with(st.clone(), "GET", "/api/auth/github/authorize", None, None).await; + let cookie = set_cookie(&headers); + let pair = cookie + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_string(); + let state = pair + .strip_prefix("atp_gh_state=") + .expect("cookie 里必须有 state") + .to_string(); + assert!(!state.is_empty(), "state 不能是空串"); + + let (status, h2, body) = req_with( + st.clone(), + "POST", + "/api/auth/github/exchange", + Some(&format!(r#"{{"code":"c1","state":"{state}"}}"#)), + Some(&pair), + ) + .await; + assert_eq!(status, StatusCode::OK, "网页流程必须换到平台凭据:{body}"); + let web_key = body["api_key"].as_str().unwrap_or_default().to_string(); + let web_uid = body["user_id"].as_i64().unwrap_or_default(); + assert!( + !web_key.is_empty() && web_uid > 0, + "字段与邮箱登录同形:{body}" + ); + assert_eq!(body["login"], "octo", "登录名一并返回:{body}"); + // 用过的 state 立刻作废 + assert!( + set_cookie(&h2).contains("Max-Age=0"), + "用过的 state 必须作废:{:?}", + set_cookie(&h2) + ); + + // 同一 GitHub 身份再从设备码进来:同一账号、同一把 key,两张表各只增一行 + let (s3, b3) = post_json( + st.clone(), + "/api/auth/github/device/token", + r#"{"device_code":"dc-abc"}"#, + ) + .await; + assert_eq!(s3, StatusCode::OK, "{b3}"); + assert_eq!( + b3["api_key"].as_str().unwrap_or_default(), + web_key, + "同一 GitHub 身份两次登录必须拿到同一把 key:{b3}" + ); + assert_eq!(b3["user_id"].as_i64().unwrap_or_default(), web_uid); + assert_eq!( + count(&st, "SELECT COUNT(*) FROM users"), + 1, + "用户行只增一次" + ); + assert_eq!( + count(&st, "SELECT COUNT(*) FROM api_keys"), + 1, + "key 行只增一次" + ); + } + + /// 登录页真的有这个入口,且中英双语都在:按钮、词表键、前端接线 + #[test] + fn the_login_page_carries_a_github_sign_in_button() { + const INDEX: &str = include_str!("../ui/index.html"); + const I18N: &str = include_str!("../ui/js/i18n.js"); + const APP: &str = include_str!("../ui/js/app.js"); + // 阳性对照:同一份语料里已知在场的登录页按钮 + assert!( + INDEX.contains(r#"id="guest-browse-btn""#), + "阳性对照失效:语料里连游客入口都没有" + ); + assert!( + INDEX.contains(r#"id="github-login-btn""#), + "登录页缺「使用 GitHub 登录」按钮" + ); + assert!( + INDEX.contains(r#"data-i18n="login.github""#), + "按钮文案必须走词表,不写死中文" + ); + assert_eq!( + I18N.matches("\"login.github\":").count(), + 2, + "词表里 login.github 必须中英各一条(缺一个就是英文模式显示中文)" + ); + assert!( + I18N.contains("\"login.github\": \"使用 GitHub 登录\""), + "缺中文值" + ); + assert!( + I18N.contains("\"login.github\": \"Sign in with GitHub\""), + "缺英文值" + ); + assert!( + APP.contains("/api/auth/github/authorize"), + "前端未取 authorize_url" + ); + assert!( + APP.contains("/api/auth/github/exchange"), + "前端未接回跳换码" + ); + } +} diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index b3e6959..03c1cee 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -51,6 +51,13 @@ const BACKEND_ERROR_SOURCES: &[(&str, &str)] = &[ include_str!("routes/admin_models.rs"), ), ("src/routes/api_keys.rs", include_str!("routes/api_keys.rs")), + // GitHub 登录(rant 2026-11-09T14:43:55):错误体是**机器码 + 英文 detail** + //(消费方是自带本地化的桌面客户端),故不新增中文文案键;名册在此登记是因为 + // 本文件**确实**发出 `"error"` 站点,漏登记就等于那几条不受任何门禁覆盖。 + ( + "src/routes/github_oauth.rs", + include_str!("routes/github_oauth.rs"), + ), ("src/routes/ops.rs", include_str!("routes/ops.rs")), ("src/routes/org.rs", include_str!("routes/org.rs")), ("src/routes/raise.rs", include_str!("routes/raise.rs")), @@ -101,11 +108,11 @@ const EN_END: &str = "\n };"; /// /// ⚠️ `T_LITERAL_COUNT` 是 `T("…")` **调用点**总数,不是键数,也不是去重后的键数 —— /// 三个集合各不相同(坑 99);说「这个数不该变」之前先确认它在数哪个集合。 -const ZH_KEY_COUNT: usize = 801; -const EN_KEY_COUNT: usize = 801; -const STATIC_ATTR_COUNT: usize = 335; -const STATIC_ATTR_DISTINCT: usize = 309; -const T_LITERAL_COUNT: usize = 548; +const ZH_KEY_COUNT: usize = 802; +const EN_KEY_COUNT: usize = 802; +const STATIC_ATTR_COUNT: usize = 336; +const STATIC_ATTR_DISTINCT: usize = 310; +const T_LITERAL_COUNT: usize = 552; const T_LITERAL_DISTINCT: usize = 435; /// 切出语言包区段(起点标记 → 终点标记,含起点)。 @@ -2684,7 +2691,7 @@ mod tests { /// 两者都数**站点**(不是去重后的文案):`READABLE` 是提取器读得到的值, /// `STRUCTURED` 是按设计不进语料的结构化信封。新增一条错误文案就是在改这两个数 —— /// 这正是它们的作用:它们证明扫描器没有变瞎,而不是证明语料静止。 - const READABLE_ERROR_WRITE_SITES: usize = 81; + const READABLE_ERROR_WRITE_SITES: usize = 95; const STRUCTURED_ERROR_WRITE_SITES: usize = 2; /// **每一处**错误文案站点的值都必须是提取器读得到的写法。 diff --git a/src/main.rs b/src/main.rs index f1eb555..a76094c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -101,6 +101,11 @@ mod tx_retention_gate; // 读者名册从 src/routes/*.rs、门槛从 src/usage_retention.rs 在编译期读入。 #[cfg(test)] mod usage_retention_gate; +// GitHub 登录门禁(rant 2026-11-09T14:43:55 验收 1):同样是仅测试期编译 —— +// src/routes/mod.rs、src/routes/github_oauth.rs 与 config/config.example.toml +// 都在编译期读入,加 #[cfg(test)] 后不会进入发布产物。 +#[cfg(test)] +mod github_oauth_gate; use std::sync::Arc; diff --git a/src/routes/github_oauth.rs b/src/routes/github_oauth.rs new file mode 100644 index 0000000..1cd8ca7 --- /dev/null +++ b/src/routes/github_oauth.rs @@ -0,0 +1,399 @@ +//! GitHub 登录(rant 2026-11-09T14:43:55):设备码流程 + 身份即账号。 +//! +//! 客户端契约(**字段名与状态码不得随意改** —— 客户端已按此实现): +//! +//! | 端点 | 请求 | 成功 | 中间态 | +//! |---|---|---|---| +//! | `POST /api/auth/github/device/code` | 无 | 200 `{device_code, user_code, verification_uri, expires_in, interval}` | — | +//! | `POST /api/auth/github/device/token` | `{device_code}` | 200 `{api_key, user_id, login}` | 202 `authorization_pending` / 429 `slow_down` / 410 `expired_token` / 403 `access_denied` | +//! +//! 成功响应的凭据字段与邮箱登录(`POST /api/auth/login`)**逐字同形**:`api_key` + +//! `user_id`;已有 key 就复用(同一个 `dao::get_or_create_api_key`),不新开第二条签发路径。 +//! +//! 中间态**绝不发 key**:GitHub 用 `200 + {"error": …}` 回答这四种状态,本模块先看 `error` +//! 再谈 `access_token`,并在那条分支上直接返回、不碰数据库。 +//! +//! **错误体是机器码 + 英文 `detail`**:本端点的消费方是自带本地化的桌面客户端 / CLI, +//! 它按 `error` 的码分支,而不是把服务端文案直接印给用户 ⇒ 文案不进词表(网页那一半 +//! 「使用 GitHub 登录」落地时,按钮文案与提示走词表)。这一点在 +//! `i18n_pack` 的 `BACKEND_ERROR_SOURCES` 名册里有登记。 +//! +//! 上游地址**可注入**([`GithubEndpoints`],挂在 `AppState::github_api` 上):默认打 +//! github.com / api.github.com,测试把它指向本地假上游。为什么是进程内字段而不是配置键 +//! ——配置项只有两个(client id / secret),验收 2 要求测试与外网解耦,而验收 6 只允许 +//! 两个配置项;于是注入点放在字段里,思路与 `gateway` 测试用 `[[plans]] endpoints.base_url` +//! 注假上游一致。 + +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use serde_json::{json, Value}; + +use super::{internal, ApiErr, AppState}; + +/// GitHub 上游地址(可注入:测试指向本地假上游,生产留默认值) +#[derive(Debug, Clone)] +pub struct GithubEndpoints { + /// OAuth 页:设备码申请 `/login/device/code` 与 token 交换 `/login/oauth/access_token` + pub oauth_base: String, + /// REST API:读身份 `GET /user` + pub api_base: String, +} + +impl Default for GithubEndpoints { + fn default() -> Self { + Self { + oauth_base: "https://github.com".to_string(), + api_base: "https://api.github.com".to_string(), + } + } +} + +/// 未启用时的统一答复:503 + 机器码 + 说清该配什么 +/// (**不是** 500 —— 没配是部署状态,不是内部故障) +fn disabled() -> ApiErr { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "error": "github_login_not_enabled", + "detail": "Set [github] client_id and client_secret to enable GitHub login.", + })), + ) +} + +/// 取已启用的凭据;未配置 → [`disabled`] +fn creds(st: &AppState) -> Result<(String, String), ApiErr> { + let g = &st.cfg.github; + if !g.enabled() { + return Err(disabled()); + } + Ok(( + g.client_id.trim().to_string(), + g.client_secret.trim().to_string(), + )) +} + +/// GitHub 要求带 User-Agent,缺了会被 403(不是 400,容易误判成权限问题) +const UA: (&str, &str) = ("user-agent", "aitokenpool"); + +/// POST /api/auth/github/device/code → 200 +/// `{device_code, user_code, verification_uri, expires_in, interval}` +pub async fn device_code(State(st): State) -> Result, ApiErr> { + let (client_id, _secret) = creds(&st)?; + let url = format!("{}/login/device/code", st.github_api.oauth_base); + let resp = st + .http + .post(&url) + .header(UA.0, UA.1) + .header("accept", "application/json") + // scope 留空:不请求 user:email —— 身份只靠 /user 的 id + login(验收 3) + .form(&[("client_id", client_id.as_str()), ("scope", "")]) + .send() + .await + .map_err(|e| internal(format!("GitHub 设备码请求失败: {e}")))?; + let status = resp.status(); + let body: Value = resp + .json() + .await + .map_err(|e| internal(format!("GitHub 设备码响应无法解析: {e}")))?; + if !status.is_success() { + return Err(( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": format!("github_device_code_rejected: {body}") })), + )); + } + // 逐字段转出:客户端只认这五个名字,上游多给的字段不外泄 + Ok(Json(json!({ + "device_code": body["device_code"].clone(), + "user_code": body["user_code"].clone(), + "verification_uri": body["verification_uri"].clone(), + "expires_in": body["expires_in"].clone(), + "interval": body["interval"].clone(), + }))) +} + +#[derive(serde::Deserialize)] +pub struct DeviceTokenReq { + pub device_code: String, +} + +/// POST /api/auth/github/device/token `{device_code}` +/// +/// 200 → `{api_key, user_id, login}`;中间态 → 202 / 429 / 410 / 403(见模块文档)。 +pub async fn device_token( + State(st): State, + Json(req): Json, +) -> Result<(StatusCode, Json), ApiErr> { + let (client_id, client_secret) = creds(&st)?; + let url = format!("{}/login/oauth/access_token", st.github_api.oauth_base); + let resp = st + .http + .post(&url) + .header(UA.0, UA.1) + .header("accept", "application/json") + .form(&[ + ("client_id", client_id.as_str()), + ("client_secret", client_secret.as_str()), + ("device_code", req.device_code.as_str()), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]) + .send() + .await + .map_err(|e| internal(format!("GitHub 设备码换取失败: {e}")))?; + let body: Value = resp + .json() + .await + .map_err(|e| internal(format!("GitHub 交换响应无法解析: {e}")))?; + // 中间态在前:GitHub 用 200 + {"error": …} 回答,先看 error 才不会把 + // 「用户还没在浏览器里确认」误判成「拿到了 token」 + if let Some(err) = body.get("error").and_then(|v| v.as_str()) { + // 每个中间态把自己的**契约码**写进响应体:客户端按 `error` 的码分支,所以这四处 + // 刻意是字面量(契约里的四个码就是这四个词),而不是把上游串原样透传。 + let (code, err_body) = match err { + "authorization_pending" => ( + StatusCode::ACCEPTED, + json!({ "error": "authorization_pending" }), + ), + "slow_down" => ( + StatusCode::TOO_MANY_REQUESTS, + json!({ "error": "slow_down" }), + ), + "expired_token" => (StatusCode::GONE, json!({ "error": "expired_token" })), + "access_denied" => (StatusCode::FORBIDDEN, json!({ "error": "access_denied" })), + other => { + return Err(( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": format!("github_exchange_failed: {other}") })), + )) + } + }; + // ⚠️ 中间态**绝不发 key**:不碰数据库,直接返回 + return Ok((code, Json(err_body))); + } + let token = body + .get("access_token") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "github_exchange_missing_access_token" })), + ) + })? + .to_string(); + let (github_id, login) = fetch_identity(&st, &token).await?; + let (user_id, api_key) = land_identity(&st, &github_id, &login)?; + log::info!("GitHub 登录成功:github_id={github_id} user_id={user_id}"); + Ok(( + StatusCode::OK, + Json(json!({ "api_key": api_key, "user_id": user_id, "login": login })), + )) +} + +/// 用 access_token 读 GitHub 身份 → `(稳定数字 id, login)` +/// +/// `id` 是 GitHub 账号的稳定主键,`login` 可改名 ⇒ 落库只认前者。 +async fn fetch_identity(st: &AppState, token: &str) -> Result<(String, String), ApiErr> { + let url = format!("{}/user", st.github_api.api_base); + let resp = st + .http + .get(&url) + .header(UA.0, UA.1) + .header("accept", "application/vnd.github+json") + .header("authorization", format!("Bearer {token}")) + .send() + .await + .map_err(|e| internal(format!("GitHub 身份请求失败: {e}")))?; + let status = resp.status(); + let body: Value = resp + .json() + .await + .map_err(|e| internal(format!("GitHub 身份响应无法解析: {e}")))?; + if !status.is_success() { + return Err(( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": format!("github_identity_rejected: {body}") })), + )); + } + let id = body.get("id").and_then(|v| v.as_i64()).ok_or_else(|| { + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "github_identity_missing_id" })), + ) + })?; + let login = body + .get("login") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "github_identity_missing_login" })), + ) + })? + .to_string(); + Ok((id.to_string(), login)) +} + +// --------------------------------------------------------------------------- +// 网页授权码流程(验收 5):登录页的「使用 GitHub 登录」 +// +// 与设备码流程**共用** [`land_identity`] —— 两条流程只差「怎么拿到 access_token」, +// 拿到之后同一条落库路径,故同一 GitHub 账号从任一入口进来都回到同一行、同一把 key。 +// --------------------------------------------------------------------------- + +/// state 的 cookie 名。授权码流程的 CSRF 绑定:本仓库没有会话层,故用一张短命 +/// HttpOnly cookie 记住「这次授权是本浏览器发起的」。 +const STATE_COOKIE: &str = "atp_gh_state"; +/// state 有效期(秒):与 GitHub 授权码的有效期同量级 +const STATE_TTL_SECS: u32 = 600; + +/// 对外可达地址(去掉尾斜杠):`redirect_uri` 由它派生。 +/// +/// 刻意**不**设成第三个配置键(验收 6 只允许 client id / secret):OAuth App 里登记的 +/// 回调地址就写成 `public_url` 本身(登录页),回头把 `code` / `state` 带回前端。 +fn redirect_uri(st: &AppState) -> String { + st.cfg.server.public_url.trim_end_matches('/').to_string() +} + +/// state cookie 的属性串:`http` 的 public_url 上不加 `Secure`,否则浏览器直接不存 +fn state_cookie(st: &AppState, value: &str, max_age: u32) -> String { + let secure = if redirect_uri(st).starts_with("https://") { + "; Secure" + } else { + "" + }; + format!("{STATE_COOKIE}={value}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Lax{secure}") +} + +/// GET /api/auth/github/authorize → 200 `{authorize_url}` + `Set-Cookie: atp_gh_state=…` +/// +/// 前端拿到 `authorize_url` 就整页跳过去;GitHub 认完再把 `code` / `state` 送回 +/// `public_url`,由前端调 [`exchange`] 换平台凭据。 +pub async fn authorize( + State(st): State, +) -> Result { + let (client_id, _secret) = creds(&st)?; + let state = uuid::Uuid::new_v4().simple().to_string(); + let mut url = reqwest::Url::parse(&format!( + "{}/login/oauth/authorize", + st.github_api.oauth_base + )) + .map_err(|e| internal(format!("GitHub 授权地址无法构造: {e}")))?; + url.query_pairs_mut() + .append_pair("client_id", &client_id) + // 由 public_url 派生(见 redirect_uri):OAuth App 里登记的就是它 + .append_pair("redirect_uri", &redirect_uri(&st)) + // 与设备码同一取舍:不请求 user:email —— 身份只靠 /user 的 id + login(验收 3) + .append_pair("scope", "") + .append_pair("state", &state); + Ok(( + StatusCode::OK, + [( + axum::http::header::SET_COOKIE, + state_cookie(&st, &state, STATE_TTL_SECS), + )], + Json(json!({ "authorize_url": url.to_string() })), + )) +} + +#[derive(serde::Deserialize)] +pub struct ExchangeReq { + pub code: String, + pub state: String, +} + +/// POST /api/auth/github/exchange `{code, state}` → 200 `{api_key, user_id, login}` +/// +/// 字段与设备码成功响应、邮箱登录**逐字同形**(验收 2)。`state` 必须与 +/// [`authorize`] 那张 cookie 逐字相同,否则 400 且**不访问上游、不碰数据库**。 +pub async fn exchange( + State(st): State, + headers: axum::http::HeaderMap, + Json(req): Json, +) -> Result< + ( + StatusCode, + [(axum::http::HeaderName, String); 1], + Json, + ), + ApiErr, +> { + let (client_id, client_secret) = creds(&st)?; + // 先验 state 再换码:伪造的 state 不该消耗 GitHub 的授权码,也不该产生任何落库 + let cookie_state = headers + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + .and_then(|raw| { + raw.split(';') + .map(str::trim) + .find_map(|kv| kv.strip_prefix(STATE_COOKIE)?.strip_prefix('=')) + }) + .unwrap_or_default() + .to_string(); + if cookie_state.is_empty() || cookie_state != req.state { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "github_state_mismatch", + "detail": "Restart sign-in from the login page: the state cookie did not match.", + })), + )); + } + let ru = redirect_uri(&st); + let url = format!("{}/login/oauth/access_token", st.github_api.oauth_base); + let resp = st + .http + .post(&url) + .header(UA.0, UA.1) + .header("accept", "application/json") + .form(&[ + ("client_id", client_id.as_str()), + ("client_secret", client_secret.as_str()), + ("code", req.code.as_str()), + // 换码时的 redirect_uri 必须与授权时那一份逐字相同,否则 GitHub 回 400 + ("redirect_uri", ru.as_str()), + ]) + .send() + .await + .map_err(|e| internal(format!("GitHub 授权码换取失败: {e}")))?; + let body: Value = resp + .json() + .await + .map_err(|e| internal(format!("GitHub 授权码响应无法解析: {e}")))?; + if let Some(err) = body.get("error").and_then(|v| v.as_str()) { + return Err(( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": format!("github_exchange_failed: {err}") })), + )); + } + let token = body + .get("access_token") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "github_exchange_missing_access_token" })), + ) + })? + .to_string(); + let (github_id, login) = fetch_identity(&st, &token).await?; + let (user_id, api_key) = land_identity(&st, &github_id, &login)?; + log::info!("GitHub 网页登录成功:github_id={github_id} user_id={user_id}"); + Ok(( + StatusCode::OK, + // 用过的 state 立刻作废:重放同一个 code/state 组合没有第二次机会 + [(axum::http::header::SET_COOKIE, state_cookie(&st, "", 0))], + Json(json!({ "api_key": api_key, "user_id": user_id, "login": login })), + )) +} + +/// 身份落库 —— 设备码与网页授权码**共用这一条路径**(验收 5)。 +/// +/// 身份只认 `github_id`(`login` 可改名);key 已有就复用,不新开第二条签发路径。 +fn land_identity(st: &AppState, github_id: &str, login: &str) -> Result<(i64, String), ApiErr> { + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let (user_id, _created) = + crate::dao::upsert_github_user(&conn, github_id, login).map_err(internal)?; + // 已有 key 就复用(验收 2):与邮箱登录同一条签发路径 + let api_key = crate::dao::get_or_create_api_key(&conn, user_id).map_err(internal)?; + Ok((user_id, api_key)) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 864849b..01fe696 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -96,6 +96,9 @@ pub(crate) fn upstream_stream_client(timeout: std::time::Duration) -> reqwest::C .expect("reqwest client 构建失败") } +/// GitHub 登录的端点实现(rant 2026-11-09T14:43:55) +pub mod github_oauth; + /// 共享状态:数据库连接 + 配置 + 路由状态 + HTTP 客户端 + 密钥加密器 + 进程启动时刻 #[derive(Clone)] pub struct AppState { @@ -110,6 +113,10 @@ pub struct AppState { /// 进程启动时刻(单调时钟):`/api/ops/runtime` 用它算运行时长。 /// 取 `Instant` 而非墙上时间——运行时长不怕系统时钟被调整。 pub started_at: std::time::Instant, + /// GitHub OAuth 上游地址:默认 github.com / api.github.com,测试改成假上游。 + /// 不是配置键 —— 「配置项只有两个」(rant 2026-11-09T14:43:55 验收 6), + /// 而验收 2 又要求测试与外网解耦,故注入点是这个字段。 + pub github_api: github_oauth::GithubEndpoints, } impl AppState { @@ -122,6 +129,7 @@ impl AppState { http_stream: upstream_stream_client(UPSTREAM_TIMEOUT), crypto, started_at: std::time::Instant::now(), + github_api: github_oauth::GithubEndpoints::default(), } } } @@ -652,6 +660,19 @@ pub fn router() -> Router { .route("/api/auth/resend-code", post(resend_code)) .route("/api/auth/forgot-password", post(forgot_password)) .route("/api/auth/reset-password", post(reset_password)) + // GitHub 登录(rant 2026-11-09T14:43:55):设备码流程,供没有浏览器回调的客户端用 + .route( + "/api/auth/github/device/code", + post(github_oauth::device_code), + ) + .route( + "/api/auth/github/device/token", + post(github_oauth::device_token), + ) + // 网页登录页的「使用 GitHub 登录」(同 rant 验收 5):普通授权码流程, + // 与设备码共用同一个身份落库函数 land_identity + .route("/api/auth/github/authorize", get(github_oauth::authorize)) + .route("/api/auth/github/exchange", post(github_oauth::exchange)) .route("/api/me", get(me)) .route("/api/config", get(config)) .route("/api/api-keys", post(api_keys::create).get(api_keys::list)) diff --git a/ui/README.md b/ui/README.md index f749aa3..20d329f 100644 --- a/ui/README.md +++ b/ui/README.md @@ -39,7 +39,7 @@ python3 -m http.server 8000 --directory ui ## 页面清单 -登录页:单一入口(邮箱登录占位),不做公共版/企业版二选一;提供「**游客浏览**」入口(US-1)。 +登录页:单一入口(邮箱登录占位 + 「**使用 GitHub 登录**」),不做公共版/企业版二选一;提供「**游客浏览**」入口(US-1)。GitHub 那个入口走普通授权码流程,与设备码流程共用同一个身份落库函数(同一 GitHub 账号从任一入口进来都是同一账号、同一把 key;rant 2026-11-09T14:43:55 验收 5)。 **游客模式(US-1)**:未登录可点击「先逛逛市场」免登录进入模型市场—— - 游客可:浏览模型、搜索、厂商筛选、排序、查看点数价格; diff --git a/ui/index.html b/ui/index.html index 81ca00c..33f1e8b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -74,6 +74,9 @@

进入平台

+ +
或
@@ -864,7 +867,7 @@

使用模型

- - + + diff --git a/ui/js/app.js b/ui/js/app.js index ab7632f..0a10dd3 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -4076,6 +4076,24 @@ // 游客浏览(US-1:登录页入口 → 免登录进入市场) $("#guest-browse-btn").addEventListener("click", enterGuest); + // 「使用 GitHub 登录」(rant 2026-11-09T14:43:55 验收 5):普通授权码流程。 + // 前端不自己造 state、也不拼授权 URL:服务端下发 state 并同时写一张 HttpOnly cookie, + // 回头用它校验 —— 否则把别人的 code 塞进本浏览器就能把本浏览器登进别人的账号。 + const ghBtn = $("#github-login-btn"); + if (ghBtn) ghBtn.addEventListener("click", async () => { + ghBtn.disabled = true; + try { + const r = await api.get("/api/auth/github/authorize"); + // 整页跳走(GitHub 认完再把 code / state 送回 public_url) + if (r && r.authorize_url) { location.href = r.authorize_url; return; } + toast(T("login.err.fail"), "error"); + } catch (err) { + toast((err && err.message) ? I18n.mapErr(err.message) : T("login.err.fail"), "error"); + } finally { + ghBtn.disabled = false; + } + }); + // 登录(P2-A:对接 POST /api/auth/login;失败行内报错;成功存 token + 拉会话) $("#login-form").addEventListener("submit", async (e) => { e.preventDefault(); @@ -4808,11 +4826,33 @@ // 把 loader 交给容器,这里不再逐个 id 登记。 $("#side-balance").textContent = D.fmt(D.USER.balance); + // GitHub 授权码回跳(rant 2026-11-09T14:43:55 验收 5):GitHub 把 code / state + // 送回 public_url(登录页)。先换平台凭据,再走**同一个** restoreSession 入口 —— + // 与邮箱登录成功后完全一致,不另开一条进场路径。 + let ghReturn = false; + (async () => { + const q = new URLSearchParams(location.search); + const code = q.get("code"); + const state = q.get("state"); + if (!code || !state) return; + ghReturn = true; + // 立刻抹掉查询串:code 只能用一次,也不该留在历史记录 / 分享出去的链接里 + history.replaceState(null, "", location.pathname + location.hash); + try { + const r = await api.post("/api/auth/github/exchange", { code, state }); + api.saveToken(r.api_key); + if (await restoreSession()) toast(T("login.welcome", { name: D.USER.name || r.login }), "success"); + } catch (err) { + toast((err && err.message) ? I18n.mapErr(err.message) : T("login.err.fail"), "error"); + } + })(); + // P2-A 会话恢复:已有 token → 拉 /api/me + /api/wallet 直接进 app。 // 失败处置集中在 restoreSession():401 回登录页(唯一「未登录」信号), // 其余失败重试一次后照常进 app 并提示 —— 不把「加载失败」演成「已登出」。 (async () => { - if (!api.getToken()) return; + // 刚回跳换码时 ghReturn 已接管(它在同步前缀里就置位),别让两条路各拉一遍会话 + if (ghReturn || !api.getToken()) return; await restoreSession(); })(); diff --git a/ui/js/i18n.js b/ui/js/i18n.js index ce80b27..f6c8dd9 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -59,6 +59,7 @@ "login.enter": "进入平台", "login.or": "或", "login.guest": "先逛逛市场", + "login.github": "使用 GitHub 登录", "login.foot": "还没有账号?注册 · 找回密码", "login.tip": "已对接后端 API · 登录后进入真实数据界面 · 管理员账号登录可见「管理视图」", "login.logging": "登录中…", @@ -903,6 +904,7 @@ "login.enter": "Enter", "login.or": "or", "login.guest": "Browse market as guest", + "login.github": "Sign in with GitHub", "login.foot": "No account? Register · Forgot password", "login.tip": "Connected to backend API · real data after login · admin accounts see the Admin view", "login.logging": "Signing in…",