From 914f057449fab3de1c9d29c912cb064c4d8052f7 Mon Sep 17 00:00:00 2001 From: argszero Date: Mon, 9 Nov 2026 15:13:42 +0800 Subject: [PATCH 1/2] feat(auth): sign in with GitHub and let the identity be the account Clients with no browser callback (desktop app, CLI, agent) could not register at all: registration is email plus a six-digit code, and the code only travels by mail. Such a client cannot read the user's GitHub email either, so "one click and the model is configured" was impossible in principle. Add the GitHub device flow, with the field names and status codes fixed as the client already implements them: 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 An intermediate state never issues a key: that branch returns without touching the database. The GitHub identity is the account. First login registers, and the same GitHub account always returns to the same account afterwards. The credential fields are byte-identical to email login (api_key + user_id), and an existing key is reused through the same dao::get_or_create_api_key -- there is no second issuance path. A new account is verified on creation (a GitHub identity is already verified, so no mail code), gets its quotas row with the account, and the daily newcomer gift is granted as usual. The identity key is GitHub's stable numeric id, not the login: a login can be renamed, and keying on it would create a second account the day it happens. Migration: users gains github_id / github_login plus a partial unique index (github_id <> ''), all idempotent; schema 17 -> 18. Columns are added rather than rebuilding the table: email and password_hash are NOT NULL, and making them nullable would force every `SELECT ... email FROM users` reader in the tree (admin user list, ops reports) to tolerate NULL -- one migration turning into a cross-module rewrite. A GitHub account therefore still writes an email, using GitHub's published noreply form +@users.noreply.github.com, and an empty password_hash, which auth::verify_password always rejects -- such an account cannot be signed into with email plus password. Config is exactly two keys, [github] client_id / client_secret. Left empty the endpoints answer 503 with the code github_login_not_enabled, not 500: an unconfigured deployment is a state, not an internal fault. The OAuth App must have Device Flow enabled; the example config says so. Tests never reach github.com: the upstream base URLs live on AppState::github_api and the gate points them at a local fake, so the two config keys stay the only config keys. The new gate asserts the contract fields, the four intermediate states against their status codes, and that the example config declares only the two credentials; four behavioural tests cover the no-key-on-pending rule, key reuse across two logins with one row each, the quota row and grantable daily gift, and the 503-not-500 answer. Every assertion carries a non-empty positive control so a decoder that went blind cannot pass on an empty set. cargo test 590 -> 601. the suite also gains five contract tests and six behavioural tests, all with non-empty positive controls; cargo test 590 -> 601, fmt and clippy clean. --- CHANGELOG.md | 4 + config/config.example.toml | 16 ++ src/config.rs | 29 +++ src/dao.rs | 51 ++++ src/db.rs | 29 ++- src/github_oauth_gate.rs | 470 +++++++++++++++++++++++++++++++++++++ src/i18n_pack.rs | 9 +- src/main.rs | 5 + src/routes/github_oauth.rs | 241 +++++++++++++++++++ src/routes/mod.rs | 17 ++ 10 files changed, 869 insertions(+), 2 deletions(-) create mode 100644 src/github_oauth_gate.rs create mode 100644 src/routes/github_oauth.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d967f5..c666746 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 登录**(设备码流程 + 身份即账号,rant 2026-11-09T14:43:55);`cargo test` 590 → 598。 + ## 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..33c5541 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -90,6 +90,22 @@ 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] +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..45d3402 --- /dev/null +++ b/src/github_oauth_gate.rs @@ -0,0 +1,470 @@ +//! 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", + ] { + // 只认路径字面量本身:路由写成一行还是折行不是契约,挂了没有才是 + assert!( + ROUTES_SRC.contains(&format!("\"{path}\"")), + "路由 {path} 未登记" + ); + } + // 处理函数也必须接在路由上(路径在、函数没接 = 路由表与实现脱节) + for handler in ["device_code", "device_token"] { + 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 点"); + } +} diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index b3e6959..9865d15 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")), @@ -2684,7 +2691,7 @@ mod tests { /// 两者都数**站点**(不是去重后的文案):`READABLE` 是提取器读得到的值, /// `STRUCTURED` 是按设计不进语料的结构化信封。新增一条错误文案就是在改这两个数 —— /// 这正是它们的作用:它们证明扫描器没有变瞎,而不是证明语料静止。 - const READABLE_ERROR_WRITE_SITES: usize = 81; + const READABLE_ERROR_WRITE_SITES: usize = 92; 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..52448e9 --- /dev/null +++ b/src/routes/github_oauth.rs @@ -0,0 +1,241 @@ +//! 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) = { + 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)?; + (user_id, api_key) + }; + 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)) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 864849b..9214028 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,15 @@ 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), + ) .route("/api/me", get(me)) .route("/api/config", get(config)) .route("/api/api-keys", post(api_keys::create).get(api_keys::list)) From 7d28847c1927a6f0cb6026be2d8748f28792d30e Mon Sep 17 00:00:00 2001 From: argszero Date: Mon, 9 Nov 2026 15:45:20 +0800 Subject: [PATCH 2/2] feat(auth): let the login page sign in with GitHub The login page had no GitHub entry, so the web route into the platform was still email plus a mail-borne code. This adds the authorisation-code half of the same feature: a "Sign in with GitHub" button, `GET /api/auth/github/authorize` which hands back an `authorize_url`, and `POST /api/auth/github/exchange` which turns the returned code into platform credentials. Both flows now meet at one function, `land_identity`, so a GitHub account reaches the same user row and the same existing key whichever entrance it used. `redirect_uri` is derived from `server.public_url` rather than added as a third config key, and `state` is checked against a short-lived HttpOnly cookie set by `authorize` -- without that binding a code minted by an attacker could sign the victim's browser into the attacker's account. A mismatched state answers 400 before any upstream call or database write. The example config now states the two switches an OAuth App needs: device flow enabled, and the callback URL set to `public_url`. cargo test 601 -> 605, fmt and clippy clean. --- CHANGELOG.md | 2 +- config/config.example.toml | 5 + src/github_oauth_gate.rs | 273 ++++++++++++++++++++++++++++++++++++- src/i18n_pack.rs | 12 +- src/routes/github_oauth.rs | 174 +++++++++++++++++++++-- src/routes/mod.rs | 4 + ui/README.md | 2 +- ui/index.html | 7 +- ui/js/app.js | 42 +++++- ui/js/i18n.js | 2 + 10 files changed, 503 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c666746..f04b75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes are recorded here. Versions follow [SemVer](https://semver.o ## Unreleased -**schema 17 → 18**;新增可选 `[github]` 段(`client_id` / `client_secret`,留空 = 未启用,端点答 503「未启用」而非 500);**GitHub 登录**(设备码流程 + 身份即账号,rant 2026-11-09T14:43:55);`cargo test` 590 → 598。 +**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) diff --git a/config/config.example.toml b/config/config.example.toml index 33c5541..5cc0c40 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -103,6 +103,11 @@ interval_secs = 60 # 扫描间隔(秒) # +@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 成对出现 diff --git a/src/github_oauth_gate.rs b/src/github_oauth_gate.rs index 45d3402..2e21911 100644 --- a/src/github_oauth_gate.rs +++ b/src/github_oauth_gate.rs @@ -50,6 +50,9 @@ mod gate { for path in [ "/api/auth/github/device/code", "/api/auth/github/device/token", + // 网页登录页的那一半(验收 5) + "/api/auth/github/authorize", + "/api/auth/github/exchange", ] { // 只认路径字面量本身:路由写成一行还是折行不是契约,挂了没有才是 assert!( @@ -58,7 +61,7 @@ mod gate { ); } // 处理函数也必须接在路由上(路径在、函数没接 = 路由表与实现脱节) - for handler in ["device_code", "device_token"] { + for handler in ["device_code", "device_token", "authorize", "exchange"] { assert!( ROUTES_SRC.contains(&format!("github_oauth::{handler}")), "处理函数 {handler} 未接到路由上" @@ -467,4 +470,272 @@ mod gate { .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 9865d15..03c1cee 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -108,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; /// 切出语言包区段(起点标记 → 终点标记,含起点)。 @@ -2691,7 +2691,7 @@ mod tests { /// 两者都数**站点**(不是去重后的文案):`READABLE` 是提取器读得到的值, /// `STRUCTURED` 是按设计不进语料的结构化信封。新增一条错误文案就是在改这两个数 —— /// 这正是它们的作用:它们证明扫描器没有变瞎,而不是证明语料静止。 - const READABLE_ERROR_WRITE_SITES: usize = 92; + const READABLE_ERROR_WRITE_SITES: usize = 95; const STRUCTURED_ERROR_WRITE_SITES: usize = 2; /// **每一处**错误文案站点的值都必须是提取器读得到的写法。 diff --git a/src/routes/github_oauth.rs b/src/routes/github_oauth.rs index 52448e9..1cd8ca7 100644 --- a/src/routes/github_oauth.rs +++ b/src/routes/github_oauth.rs @@ -181,14 +181,7 @@ pub async fn device_token( })? .to_string(); let (github_id, login) = fetch_identity(&st, &token).await?; - let (user_id, api_key) = { - 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)?; - (user_id, api_key) - }; + 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, @@ -239,3 +232,168 @@ async fn fetch_identity(st: &AppState, token: &str) -> Result<(String, String), .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 9214028..01fe696 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -669,6 +669,10 @@ pub fn router() -> Router { "/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…",