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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@

# macOS
.DS_Store
/data
71 changes: 60 additions & 11 deletions Cargo.lock

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

7 changes: 4 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aitokenpool"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
description = "AI Token 共享池 — 企业 key 池 + 公共共享市场"
license = "MIT"
Expand All @@ -11,8 +11,8 @@ rust-version = "1.86"
# HTTP 服务器(复用 openlocalrouter 技术栈)
axum = "0.7"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "signal"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "fs"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.5", features = ["cors", "fs", "trace"] }
hyper = { version = "1.0", features = ["full"] }

# HTTP 客户端(上游转发)
Expand All @@ -21,6 +21,7 @@ reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
# 序列化
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
toml = "0.8"

# 数据库
rusqlite = { version = "0.31", features = ["bundled"] }
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke

## 状态

🚧 项目初始化中(2026-08-13)
- ✅ **P0-A(v0.2.0,2026-08-17)**:后端骨架 + 配置加载(`config/config.example.toml`)+ SQLite 数据层(幂等迁移 + demo 种子)+ 认证(argon2 + Bearer API Key)+ API Key 端点。`cargo run` 后:
- `GET /healthz` → `{"status":"ok","version":"0.2.0"}`
- `POST /api/auth/login`(demo@aitokenpool.local / demo1234)→ `{api_key}`
- `POST|GET /api/api-keys`(Bearer 认证,key 脱敏 `atk_live_****xxxx`)
- 🚧 P0 后续(网关路由 / 用量追踪)进行中;UI 原型 v1.20(`ui/` 静态页 + mock 数据)。

## License

Expand Down
7 changes: 7 additions & 0 deletions config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
# 由脚本从 OpenRouter/litellm 同步生成,本文件只放「官方价覆盖」。
# - 分层理由见 docs/plan-api-matrix.md 与架构讨论。

# ============================================================
# 0. 服务(监听地址 / 数据库路径)— P0-A 起可配
# ============================================================
[server]
addr = "0.0.0.0:8080"
db_path = "data/aitokenpool.db"

# ============================================================
# 1. 点数规则(账本层的锚)
# ============================================================
Expand Down
70 changes: 70 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! 认证:argon2 口令哈希 + API Key 生成/校验
//!
//! P0-A(rant 2026-08-17T22:21:52):
//! - POST /api/auth/login:email+password → argon2 校验 → 返回该用户有效 API Key(无则生成)
//! - Bearer <api_key> 认证:查 api_keys 表 → 注入用户身份;无效 401

use anyhow::{anyhow, Result};
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)
pub fn hash_password(pw: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
Ok(Argon2::default()
.hash_password(pw.as_bytes(), &salt)
.map_err(|e| anyhow!("argon2 哈希失败: {e}"))?
.to_string())
}

/// 校验口令是否匹配存储哈希
pub fn verify_password(hash: &str, pw: &str) -> bool {
let Ok(parsed) = PasswordHash::new(hash) else {
return false;
};
Argon2::default()
.verify_password(pw.as_bytes(), &parsed)
.is_ok()
}

/// 生成分发 API Key:`atk_live_` + 24 位 hex(12 随机字节),与 UI 原型一致
pub fn generate_api_key() -> String {
let mut bytes = [0u8; 12];
rand::rngs::OsRng.fill_bytes(&mut bytes);
format!("atk_live_{}", hex::encode(bytes))
}

/// API Key 脱敏展示:atk_live_****xxxx(保留后 4 位)
pub fn mask_api_key(key: &str) -> String {
if key.len() > 8 {
let tail = &key[key.len() - 4..];
format!("atk_live_****{tail}")
} else {
"****".to_string()
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn hash_verify_roundtrip() {
let h = hash_password("demo1234").unwrap();
assert!(verify_password(&h, "demo1234"));
assert!(!verify_password(&h, "wrong"));
}

#[test]
fn api_key_format_and_mask() {
let k = generate_api_key();
assert!(k.starts_with("atk_live_"));
assert_eq!(k.len(), "atk_live_".len() + 24);
let m = mask_api_key(&k);
assert_eq!(m, format!("atk_live_****{}", &k[k.len() - 4..]));
assert!(!m.contains(&k[..k.len() - 4]));
}
}
Loading
Loading