diff --git a/Cargo.lock b/Cargo.lock index ef67cfc..66aefa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.6.0" +version = "0.6.1" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 8e059e1..45bc664 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.6.0" +version = "0.6.1" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT" diff --git a/README.md b/README.md index b5f139d..4ccf82c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke - ✅ **发布(v0.5.1)**:GitHub Actions Docker 发布——`main` push / `v*` tag 自动构建推送 GHCR 镜像 `ghcr.io/argszero/aitokenpool`(buildx + gha 缓存) - ✅ **v0.5.2(bugfix)**:修复 3 个时间敏感测试——测试硬编码日期(2026-08-18)跨天后导致赠送过期断言失败,改为 SQLite 动态日期(`datetime('now')` / `strftime('%Y-%m-%d 23:59:59', 'now')`),测试不再随日期推移周期失败 - ✅ **v0.6.0**:**移除所有 demo 种子数据(rant 2026-08-19T10:41:03)**——首次部署 = 干净空库(只建表),不再预置 demo/admin/ops 假账号、假余额、占位 key;测试改用 `#[cfg(test)]` 专用 `seed_test_users` 辅助(生产构建不含);UI 登录页/设置页移除演示账号预填与提示 +- ✅ **v0.6.1**:**首次启动自动创建初始管理员(rant 2026-08-19T14:35:05)**——空库启动时创建 `admin@aitokenpool.local` + 随机 16 位密码(打印到启动日志,仅首次)+ quotas 账户(balance=0),幂等不重复;新增 `POST /api/auth/change-password` 改密端点(旧密码校验 + argon2 更新);不再需要手工插库 `ui/` 已由纯静态原型升级为**对接真实 API**(登录、钱包、市场、共享、交易、设置、管理、运营全部真实数据),由后端 `ServeDir` 静态托管,无需单独部署前端。 @@ -68,22 +69,29 @@ open http://localhost:8080/ # 浏览器访问 ### ③ 登录账号 -首次部署为**干净空库**(只建表,不预置任何账号)。首个管理员需手动创建: +首次启动(空库)**自动创建初始管理员账号**(rant 2026-08-19T14:35:05,开源项目惯例): -```bash -# 先启动一次服务生成数据目录与表结构,然后: -# 密码哈希由程序生成(argon2);示例仅为占位 -sqlite3 data/aitokenpool.db \ - "INSERT INTO users (email, password_hash, name, role) VALUES ('admin@example.com', '', '管理员', 'admin'); - INSERT INTO quotas (user_id, balance) VALUES (1, 0);" -``` +1. 启动服务,查看**启动日志**中的初始管理员凭据(仅首次打印): + - 账号:`admin@aitokenpool.local` + - 密码:随机生成的 16 位字母数字(标注「初始管理员密码,请立即修改」) +2. 用该账号登录(登录页邮箱 + 日志中的密码) +3. 登录后**立即修改密码**:`POST /api/auth/change-password`(旧密码 + 新密码,Bearer 认证) +> 初始管理员只创建一次(users 表为空时);已有用户后重启不会重复创建。 +> 若日志丢失,也可直接操作 SQLite 手动建号(备选方案): +> +> ```bash +> sqlite3 data/aitokenpool.db \ +> "INSERT INTO users (email, password_hash, name, role) VALUES ('admin@example.com', '', '管理员', 'admin'); +> INSERT INTO quotas (user_id, balance) VALUES (1, 0);" +> ``` +> > 后续账号通过注册/邀请流程创建(当前版本未提供注册端点,可直接操作 SQLite)。 ## API 端点(Bearer 认证) -- `GET /healthz` → `{"status":"ok","version":"0.6.0"}` -- `POST /api/auth/login` → `{api_key}`;`GET /api/me` → `{id,email,name,role}` +- `GET /healthz` → `{"status":"ok","version":"0.6.1"}` +- `POST /api/auth/login` → `{api_key}`;`POST /api/auth/change-password`(改密);`GET /api/me` → `{id,email,name,role}` - `POST|GET /api/api-keys`(key 脱敏 `atk_live_****xxxx`);`DELETE /api/api-keys/:id`(撤销) - `POST /v1/chat/completions` / `POST /anthropic/v1/messages` / `POST /v1/responses`(网关,三协议互转,非流式 + 流式 SSE 跨协议转换);`GET /v1/models`(OpenAI 兼容模型列表,认证可选) - `GET /api/models`(模型市场) diff --git a/src/auth.rs b/src/auth.rs index 2b724d0..cb14f77 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -4,18 +4,15 @@ //! - POST /api/auth/login:email+password → argon2 校验 → 返回该用户有效 API Key(无则生成) //! - Bearer 认证:查 api_keys 表 → 注入用户身份;无效 401 -// 仅测试使用(hash_password 为 cfg(test);生产仅需 verify) -#[cfg(test)] use anyhow::{anyhow, Result}; -#[cfg(test)] -use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString}; -use argon2::password_hash::{PasswordHash, PasswordVerifier}; +use argon2::password_hash::{ + rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, +}; use argon2::Argon2; use rand::RngCore; /// argon2 口令哈希(OWASP 默认参数:m=19MiB, t=2, p=1) -/// v0.6.0 起仅测试使用(生产空库不种账号,注册端点未实现前无生产调用点) -#[cfg(test)] +/// v0.6.1 起生产使用:bootstrap 初始管理员建号 + 改密端点(rant 2026-08-19T14:35:05) pub fn hash_password(pw: &str) -> Result { let salt = SaltString::generate(&mut OsRng); Ok(Argon2::default() diff --git a/src/db.rs b/src/db.rs index 05e9f5b..e81d74d 100644 --- a/src/db.rs +++ b/src/db.rs @@ -248,6 +248,37 @@ pub fn migrate_key_encryption(conn: &Connection, crypto: &crate::crypto::Crypto) Ok(n) } +/// 首次启动自动创建初始管理员(rant 2026-08-19T14:35:05:开源项目惯例——安装完应有默认管理员)。 +/// - 仅当 users 表为空时创建:`admin@aitokenpool.local` + 随机 16 位字母数字密码(非硬编码) +/// + 其 quotas 账户(balance=0);不创建 demo/ops 账号、不种余额、不种占位 key。 +/// - 幂等:已有用户则跳过(重启不重复),返回 `None`;创建成功返回明文密码 +/// (由调用方打印到启动日志,仅首次、明确标注「初始管理员密码,请立即修改」)。 +pub fn bootstrap_admin(conn: &Connection) -> Result> { + use rand::distributions::Alphanumeric; + use rand::Rng; + + let users: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))?; + if users > 0 { + return Ok(None); + } + let pw: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect(); + let hash = crate::auth::hash_password(&pw)?; + conn.execute( + "INSERT INTO users (email, password_hash, name, role) VALUES (?1, ?2, '管理员', 'admin')", + rusqlite::params!["admin@aitokenpool.local", hash], + )?; + let id = conn.last_insert_rowid(); + conn.execute( + "INSERT OR IGNORE INTO quotas (user_id, balance) VALUES (?1, 0)", + [id], + )?; + Ok(Some(pw)) +} + /// 测试专用:自建测试用户(demo/admin/ops + 配额 + 占位 key),复刻旧 seed() 行为。 /// 生产 open() 不再种任何数据(rant 2026-08-19T10:41:03:移除所有 demo 种子数据,一律生产标准)。 #[cfg(test)] @@ -467,6 +498,74 @@ mod tests { let _ = std::fs::remove_file(p); } + #[test] + fn bootstrap_admin_creates_on_empty_db_and_is_idempotent() { + // rant 2026-08-19T14:35:05:空库首次启动 → 初始管理员 + 随机密码 + quotas(0) + let (conn, p) = tmp_db("bootadmin"); + let pw = bootstrap_admin(&conn) + .expect("bootstrap 1st") + .expect("有密码"); + assert_eq!(pw.len(), 16, "密码 16 位字母数字"); + assert!( + pw.chars().all(|c| c.is_ascii_alphanumeric()), + "密码为字母数字" + ); + let (email, role, hash): (String, String, String) = conn + .query_row( + "SELECT email, role, password_hash FROM users WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(email, "admin@aitokenpool.local"); + assert_eq!(role, "admin"); + assert!(crate::auth::verify_password(&hash, &pw), "密码与日志一致"); + assert!( + !crate::auth::verify_password(&hash, "wrong-password"), + "错误密码不匹配" + ); + let (balance,): (f64,) = conn + .query_row("SELECT balance FROM quotas WHERE user_id = 1", [], |r| { + Ok((r.get(0)?,)) + }) + .unwrap(); + assert_eq!(balance, 0.0, "初始管理员余额为 0"); + // 幂等:再启动不重复创建、不再返回密码 + assert!(bootstrap_admin(&conn).expect("bootstrap 2nd").is_none()); + let users: i64 = conn + .query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0)) + .unwrap(); + assert_eq!(users, 1, "仅一个初始管理员"); + let keys: i64 = conn + .query_row("SELECT COUNT(*) FROM keys", [], |r| r.get(0)) + .unwrap(); + assert_eq!(keys, 0, "不种占位 key"); + drop(conn); + let _ = std::fs::remove_file(p); + } + + #[test] + fn bootstrap_admin_skips_when_users_exist() { + // 已有用户(测试库)→ bootstrap 跳过,不重复创建 + let (conn, p) = tmp_db("bootskip"); + seed_test_users(&conn).expect("seed test users"); + assert!(bootstrap_admin(&conn).expect("bootstrap").is_none()); + let users: i64 = conn + .query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0)) + .unwrap(); + assert_eq!(users, 3, "已有用户不受影响"); + let admin: i64 = conn + .query_row( + "SELECT COUNT(*) FROM users WHERE email = 'admin@aitokenpool.local'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(admin, 1, "测试库自带 admin 仍在(非 bootstrap 创建)"); + drop(conn); + let _ = std::fs::remove_file(p); + } + #[test] fn seed_models_upserts_from_example_and_overrides() { let (conn, p) = tmp_db("models"); diff --git a/src/main.rs b/src/main.rs index cdc9380..a358d19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,17 @@ async fn main() -> anyhow::Result<()> { let conn = db::open(&db_path)?; db::seed_models(&conn, &cfg)?; + // 首次启动自动创建初始管理员(rant 2026-08-19T14:35:05):仅空库时创建, + // 密码随机生成、仅此一次打印到启动日志,提示立即修改 + if let Some(pw) = db::bootstrap_admin(&conn)? { + log::warn!("============================================================"); + log::warn!("⚠️ 初始管理员账号已自动创建(仅首次启动)"); + log::warn!("⚠️ 账号: admin@aitokenpool.local"); + log::warn!("⚠️ 初始管理员密码: {pw}"); + log::warn!("⚠️ 请立即登录并修改密码(POST /api/auth/change-password)"); + log::warn!("============================================================"); + } + // P0-C:主密钥 + 旧明文 key 加密迁移 let crypto = crypto::Crypto::from_config(&cfg.server.master_key); let migrated = db::migrate_key_encryption(&conn, &crypto)?; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 4b90a41..8839808 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -163,11 +163,51 @@ pub async fn me( }))) } +#[derive(Deserialize)] +pub struct ChangePasswordReq { + pub old_password: String, + pub new_password: String, +} + +/// POST /api/auth/change-password:旧密码校验 + 新密码 argon2 更新(rant 2026-08-19T14:35:05, +/// 初始管理员登录后改密;Bearer 认证,任意已登录用户可改自己的密码) +pub async fn change_password( + State(st): State, + auth: AuthUser, + Json(req): Json, +) -> Result, ApiErr> { + if req.new_password.len() < 8 { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "新密码至少 8 位" })), + )); + } + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let hash: String = conn + .query_row( + "SELECT password_hash FROM users WHERE id = ?1", + [auth.user_id], + |r| r.get(0), + ) + .map_err(|_| unauthorized())?; + if !crate::auth::verify_password(&hash, &req.old_password) { + return Err(unauthorized()); + } + let new_hash = crate::auth::hash_password(&req.new_password).map_err(internal)?; + conn.execute( + "UPDATE users SET password_hash = ?1 WHERE id = ?2", + rusqlite::params![new_hash, auth.user_id], + ) + .map_err(internal)?; + Ok(Json(serde_json::json!({ "status": "ok" }))) +} + /// 组装路由:API 路由优先,其余请求回退到 ui/ 静态托管(P2-A) pub fn router() -> Router { Router::new() .route("/healthz", get(healthz)) .route("/api/auth/login", post(login)) + .route("/api/auth/change-password", post(change_password)) .route("/api/me", get(me)) .route("/api/api-keys", post(api_keys::create).get(api_keys::list)) .route("/api/api-keys/:id", axum::routing::delete(api_keys::remove)) @@ -593,6 +633,69 @@ mod tests { assert_eq!(v["role"], "admin"); } + #[tokio::test] + async fn change_password_rotates_and_old_stops_working() { + // rant 2026-08-19T14:35:05:初始管理员改密端点——旧密码校验 + argon2 更新 + let st = test_state("chpw"); + let bearer = login_bearer(&st, "demo@aitokenpool.local", "demo1234").await; + // 错误旧密码 → 401 + let (s, body) = post( + st.clone(), + "/api/auth/change-password", + r#"{"old_password":"wrong","new_password":"newpass123"}"#, + Some(&bearer), + ) + .await; + assert_eq!(s, StatusCode::UNAUTHORIZED, "旧密码错误应 401: {body}"); + // 过短新密码 → 400 + let (s, _) = post( + st.clone(), + "/api/auth/change-password", + r#"{"old_password":"demo1234","new_password":"short"}"#, + Some(&bearer), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST, "新密码不足 8 位应 400"); + // 正确改密 → 200 + let (s, body) = post( + st.clone(), + "/api/auth/change-password", + r#"{"old_password":"demo1234","new_password":"brandnew99"}"#, + Some(&bearer), + ) + .await; + assert_eq!(s, StatusCode::OK, "改密应 200: {body}"); + // 旧密码登录失败、新密码登录成功 + let (s, _) = post( + st.clone(), + "/api/auth/login", + r#"{"email":"demo@aitokenpool.local","password":"demo1234"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::UNAUTHORIZED, "旧密码应失效"); + let (s, body) = post( + st, + "/api/auth/login", + r#"{"email":"demo@aitokenpool.local","password":"brandnew99"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::OK, "新密码应可登录: {body}"); + } + + #[tokio::test] + async fn change_password_requires_bearer() { + let (s, _) = post( + test_state("chpw401"), + "/api/auth/change-password", + r#"{"old_password":"x","new_password":"yyyyyyyy"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::UNAUTHORIZED, "未认证应 401"); + } + /* ---- P2-C:部门管理 / 加额审批 / 运营者 / 用量三组聚合 ---- */ #[tokio::test]