Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aitokenpool"
version = "0.6.0"
version = "0.6.1"
edition = "2021"
description = "AI Token 共享池 — 企业 key 池 + 公共共享市场"
license = "MIT"
Expand Down
28 changes: 18 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 静态托管,无需单独部署前端。

Expand Down Expand Up @@ -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', '<argon2-hash>', '管理员', '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', '<argon2-hash>', '管理员', '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`(模型市场)
Expand Down
11 changes: 4 additions & 7 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,15 @@
//! - POST /api/auth/login:email+password → argon2 校验 → 返回该用户有效 API Key(无则生成)
//! - Bearer <api_key> 认证:查 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<String> {
let salt = SaltString::generate(&mut OsRng);
Ok(Argon2::default()
Expand Down
99 changes: 99 additions & 0 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<String>> {
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)]
Expand Down Expand Up @@ -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");
Expand Down
11 changes: 11 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
103 changes: 103 additions & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
auth: AuthUser,
Json(req): Json<ChangePasswordReq>,
) -> Result<Json<serde_json::Value>, 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<AppState> {
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))
Expand Down Expand Up @@ -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]
Expand Down
Loading