From 0d932396e6472fbd28709e79a0407a5aa907472b Mon Sep 17 00:00:00 2001 From: argszero Date: Wed, 19 Aug 2026 14:53:40 +0800 Subject: [PATCH] feat(auth): self-registration with email verification (v0.6.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rant 2026-08-19T14:36:19: 支持用户自助注册 — POST /api/auth/register + 登录页注册入口(方案 B 完整验证码)。 - POST /api/auth/register: 邮箱格式 + 密码≥8 校验;email 已存在 409;创建未验证用户 (verified=0, role=user) + 6 位数字验证码(10 分钟有效,sha256 存储,5 次错误失效) - POST /api/auth/verify: 校验验证码 → 激活用户 + 建 quotas(0) - POST /api/auth/resend-code: 60 秒重发限频 - login: 未验证邮箱 403「邮箱未验证」 - [mail] 配置段(lettre SMTP STARTTLS);未配置 → dev 模式验证码打日志/响应 - users.verified 迁移 v6(默认 1:存量用户视为已信任,升级不锁号) - UI: 登录页注册表单 + 验证码页(中英 i18n + ERR_MAP) - README 快速上手 §④ 注册账号 + [mail] 说明 - 111/111 tests + clippy + fmt + node check + live smoke 全过 --- Cargo.lock | 81 +++++++- Cargo.toml | 5 +- README.md | 19 +- config/config.example.toml | 14 ++ src/config.rs | 35 ++++ src/dao.rs | 104 ++++++++++ src/db.rs | 20 +- src/mail.rs | 63 +++++++ src/main.rs | 1 + src/routes/mod.rs | 377 ++++++++++++++++++++++++++++++++++++- ui/css/style.css | 11 ++ ui/index.html | 36 +++- ui/js/app.js | 124 ++++++++++++ ui/js/i18n.js | 59 ++++++ 14 files changed, 938 insertions(+), 11 deletions(-) create mode 100644 src/mail.rs diff --git a/Cargo.lock b/Cargo.lock index 66aefa4..50d30da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.6.1" +version = "0.6.2" dependencies = [ "aes-gcm", "anyhow", @@ -74,6 +74,7 @@ dependencies = [ "futures-util", "hex", "hyper", + "lettre", "log", "rand 0.8.7", "reqwest", @@ -284,6 +285,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -552,6 +559,22 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -816,6 +839,17 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "1.5.0" @@ -927,7 +961,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1157,6 +1191,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "url", + "webpki-roots", +] + [[package]] name = "libc" version = "0.2.189" @@ -1254,6 +1313,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1472,6 +1540,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "6.0.0" @@ -1569,7 +1643,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -1663,6 +1737,7 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", diff --git a/Cargo.toml b/Cargo.toml index 45bc664..3a12152 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.6.1" +version = "0.6.2" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT" @@ -47,3 +47,6 @@ sha2 = "0.10" hex = "0.4" rand = "0.8" clap = { version = "4", features = ["derive"] } + +# 注册邮箱验证码发信(rant 2026-08-19T14:36:19:方案 B 完整验证码) +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "rustls-tls", "hostname", "pool"] } diff --git a/README.md b/README.md index 4ccf82c..fdd1da9 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke - ✅ **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 更新);不再需要手工插库 +- ✅ **v0.6.2**:**用户自助注册 + 邮箱验证(rant 2026-08-19T14:36:19 方案 B)**——`POST /api/auth/register` + `verify` + `resend-code`;6 位数字验证码(10 分钟有效、5 次错误失效、60 秒重发限频);未验证邮箱不可登录(403);登录页注册表单 + 验证码页(中英 i18n);SMTP 发信(`[mail]` 配置,未配置时 dev 模式验证码打日志/响应) `ui/` 已由纯静态原型升级为**对接真实 API**(登录、钱包、市场、共享、交易、设置、管理、运营全部真实数据),由后端 `ServeDir` 静态托管,无需单独部署前端。 @@ -85,13 +86,25 @@ open http://localhost:8080/ # 浏览器访问 > "INSERT INTO users (email, password_hash, name, role) VALUES ('admin@example.com', '', '管理员', 'admin'); > INSERT INTO quotas (user_id, balance) VALUES (1, 0);" > ``` -> + +### ④ 注册账号(普通用户自助注册,v0.6.2) + +除内置管理员外,普通用户**自助注册**(登录页底部「注册」): + +1. 点击登录页「注册」→ 填昵称(可选)/ 邮箱 / 密码(≥8 位)→ 提交 +2. 邮箱收到 **6 位验证码**(10 分钟有效)→ 输入验证码完成激活 +3. 激活后回登录页用新账号登录(首次进钱包自动获得每日赠送 1 点) + +> **邮件服务配置**(生产必配):`config/config.example.toml` 的 `[mail]` 段 +> (smtp_host / smtp_port / smtp_user / smtp_password / from / from_name)。 +> **未配置 SMTP 时为 dev 模式**:验证码直接打印到服务端日志(并随注册响应返回 `dev_code`),仅适合本地试用。 + > 后续账号通过注册/邀请流程创建(当前版本未提供注册端点,可直接操作 SQLite)。 ## API 端点(Bearer 认证) -- `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}` +- `GET /healthz` → `{"status":"ok","version":"0.6.2"}` +- `POST /api/auth/login` → `{api_key}`;`POST /api/auth/change-password`(改密);`POST /api/auth/register|verify|resend-code`(注册+邮箱验证);`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/config/config.example.toml b/config/config.example.toml index 1b852b3..54dce5f 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -17,6 +17,20 @@ db_path = "data/aitokenpool.db" # 所有已上架 key 将解密失败(503)。生成:openssl rand -hex 32 # master_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +# ============================================================ +# 0.1 邮件服务(注册验证码,rant 2026-08-19T14:36:19 方案 B) +# 未配置时进入 dev 模式:验证码打印到日志/响应(便于本地测试)。 +# ⚠️ 生产部署必须配置 SMTP(STARTTLS,默认 587),否则验证码邮件不会真正送达。 +# ============================================================ +# [mail] +# smtp_host = "smtp.example.com" +# smtp_port = 587 +# smtp_user = "noreply@example.com" +# smtp_password = "your-smtp-password" +# from = "noreply@example.com" +# from_name = "AITokenPool" +# verify_subject = "AITokenPool 邮箱验证码" + # ============================================================ # 1. 点数规则(账本层的锚) # ============================================================ diff --git a/src/config.rs b/src/config.rs index 7324391..9424d6c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,12 +37,47 @@ impl Default for Server { } } +/// 邮件服务(注册验证码,rant 2026-08-19T14:36:19 方案 B)。 +/// 未配置(smtp_host 为空)时进入 dev 模式:验证码打印到日志/响应,便于本地测试; +/// 生产部署必须配置 SMTP,否则注册验证码不真正送达(安全风险由部署方承担)。 +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Mail { + #[serde(default)] + pub smtp_host: String, + #[serde(default = "default_smtp_port")] + pub smtp_port: u16, + #[serde(default)] + pub smtp_user: String, + #[serde(default)] + pub smtp_password: String, + #[serde(default)] + pub from: String, + #[serde(default)] + pub from_name: String, + #[serde(default)] + pub verify_subject: String, +} + +fn default_smtp_port() -> u16 { + 587 +} + +impl Mail { + /// 是否配置了真实 SMTP(生产模式) + pub fn configured(&self) -> bool { + !self.smtp_host.is_empty() + } +} + /// 顶层配置 #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] // P0-A 仅用 server;points/providers/plans 由后续 P0 网关/定价阶段消费(parse 测试已校验) pub struct Config { #[serde(default)] pub server: Server, + /// 邮件服务(可选;未配置时注册验证码走 dev 日志模式) + #[serde(default)] + pub mail: Mail, pub points: Points, pub providers: Vec, pub plans: Vec, diff --git a/src/dao.rs b/src/dao.rs index c5a8556..56e6170 100644 --- a/src/dao.rs +++ b/src/dao.rs @@ -233,3 +233,107 @@ pub fn verify_user_password(conn: &Connection, email: &str, pw: &str) -> Result< Err(anyhow!("口令错误")) } } + +/* ---- 注册 / 邮箱验证(rant 2026-08-19T14:36:19 方案 B)---- */ + +/// 邮箱是否已注册(含未验证用户) +pub fn email_taken(conn: &Connection, email: &str) -> bool { + conn.query_row("SELECT 1 FROM users WHERE email = ?1", [email], |_| Ok(())) + .is_ok() +} + +/// 创建未验证用户(role='user',verified=0)→ 返回 user_id;不建 quotas(验证通过后建) +pub fn create_unverified_user( + conn: &Connection, + email: &str, + name: &str, + password_hash: &str, +) -> Result { + conn.execute( + "INSERT INTO users (email, password_hash, name, role, verified) VALUES (?1, ?2, ?3, 'user', 0)", + rusqlite::params![email, password_hash, name], + )?; + Ok(conn.last_insert_rowid()) +} + +/// 用户是否已验证(users.verified == 1) +pub fn user_verified(conn: &Connection, user_id: i64) -> bool { + conn.query_row("SELECT verified FROM users WHERE id = ?1", [user_id], |r| { + r.get::<_, i64>(0) + }) + .map(|v| v == 1) + .unwrap_or(false) +} + +/// 验证通过后激活用户 + 建 quotas 账户(balance=0, gift_balance=0) +pub fn activate_user(conn: &Connection, email: &str) -> Result<()> { + conn.execute("UPDATE users SET verified = 1 WHERE email = ?1", [email])?; + let id: i64 = conn.query_row("SELECT id FROM users WHERE email = ?1", [email], |r| { + r.get(0) + })?; + conn.execute( + "INSERT OR IGNORE INTO quotas (user_id, balance, gift_balance) VALUES (?1, 0, 0)", + [id], + )?; + Ok(()) +} + +/// 存储验证码(6 位数字的 sha256 hex);同一邮箱重复注册/重发 → 覆盖旧码 +pub fn store_verification_code( + conn: &Connection, + email: &str, + code_hash: &str, + expires_at: &str, +) -> Result<()> { + conn.execute( + "INSERT OR REPLACE INTO email_verifications (email, code_hash, expires_at, attempts) \ + VALUES (?1, ?2, ?3, 0)", + rusqlite::params![email, code_hash, expires_at], + )?; + Ok(()) +} + +/// 取验证记录 → Some((code_hash, attempts));过期或不存在 → None +pub fn find_valid_verification(conn: &Connection, email: &str) -> Option<(String, i64)> { + conn.query_row( + "SELECT code_hash, attempts FROM email_verifications \ + WHERE email = ?1 AND expires_at > datetime('now')", + [email], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .ok() +} + +/// 最近一次发送时间距现在是否 < 60 秒(重发限频) +pub fn resend_too_soon(conn: &Connection, email: &str) -> bool { + conn.query_row( + "SELECT 1 FROM email_verifications WHERE email = ?1 AND created_at > datetime('now', '-60 seconds')", + [email], + |_| Ok(()), + ) + .is_ok() +} + +/// 校验失败计数 +1;返回失败后是否已达上限(≥5 → 删除记录) +pub fn bump_verification_attempt(conn: &Connection, email: &str) -> Result { + conn.execute( + "UPDATE email_verifications SET attempts = attempts + 1 WHERE email = ?1", + [email], + )?; + let attempts: i64 = conn.query_row( + "SELECT attempts FROM email_verifications WHERE email = ?1", + [email], + |r| r.get(0), + )?; + if attempts >= 5 { + conn.execute("DELETE FROM email_verifications WHERE email = ?1", [email])?; + return Ok(true); + } + Ok(false) +} + +/// 验证成功后删除验证码记录 +pub fn clear_verification(conn: &Connection, email: &str) -> Result<()> { + conn.execute("DELETE FROM email_verifications WHERE email = ?1", [email])?; + Ok(()) +} diff --git a/src/db.rs b/src/db.rs index e81d74d..ffba305 100644 --- a/src/db.rs +++ b/src/db.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result}; use rusqlite::Connection; -pub const SCHEMA_VERSION: i64 = 5; +pub const SCHEMA_VERSION: i64 = 6; /// 打开(或创建)数据库并执行幂等迁移(生产标准:空库只建表,不种任何假数据) pub fn open(path: &str) -> Result { @@ -196,6 +196,24 @@ pub fn migrate(conn: &Connection) -> Result<()> { "context_window", "context_window INTEGER NOT NULL DEFAULT 1048576", )?; + // v6(rant 2026-08-19T14:36:19):注册邮箱验证——users.verified + email_verifications 表。 + // 注意:verified 默认 1(存量用户视为已信任,升级不锁号;新注册显式写 0 待验证) + ensure_column( + conn, + "users", + "verified", + "verified INTEGER NOT NULL DEFAULT 1", + )?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS email_verifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + code_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + );", + )?; // schema_version:INSERT OR REPLACE 保证幂等 let v: i64 = conn .query_row("SELECT version FROM schema_version", [], |r| r.get(0)) diff --git a/src/mail.rs b/src/mail.rs new file mode 100644 index 0000000..7de4cb1 --- /dev/null +++ b/src/mail.rs @@ -0,0 +1,63 @@ +//! 邮件发信(注册验证码,rant 2026-08-19T14:36:19 方案 B) +//! +//! - 配置了 SMTP(config [mail].smtp_host 非空)→ 真实发信(STARTTLS,默认 587) +//! - 未配置 → dev 模式:验证码打印到日志(便于本地测试;生产必须配置 SMTP) + +use anyhow::{Context, Result}; + +use crate::config::Mail; +use lettre::Transport; + +/// 发送验证码邮件;dev 模式(未配置 SMTP)时仅打日志并返回 Ok +pub fn send_verification_code(cfg: &Mail, to: &str, code: &str) -> Result<()> { + if !cfg.configured() { + log::warn!("[dev] 注册验证码(未配置 SMTP,仅日志;生产请配置 [mail]):{to} → {code}"); + return Ok(()); + } + let subject = if cfg.verify_subject.is_empty() { + "AITokenPool 邮箱验证码".to_string() + } else { + cfg.verify_subject.clone() + }; + let from = if cfg.from.is_empty() { + "noreply@aitokenpool.local".to_string() + } else { + cfg.from.clone() + }; + let from_name = if cfg.from_name.is_empty() { + "AITokenPool".to_string() + } else { + cfg.from_name.clone() + }; + let body = format!( + "您的 AITokenPool 注册验证码是:{code},10 分钟内有效。\n\ + 如非本人操作请忽略本邮件。\n\ + Your AITokenPool verification code: {code} (valid for 10 minutes)." + ); + + let email = lettre::Message::builder() + .from( + format!("{from_name} <{from}>") + .parse() + .context("from 邮箱格式非法")?, + ) + .to(to.parse().context("收件邮箱格式非法")?) + .subject(subject) + .body(body) + .context("构建邮件失败")?; + + let creds = lettre::transport::smtp::authentication::Credentials::new( + cfg.smtp_user.clone(), + cfg.smtp_password.clone(), + ); + let mailer = lettre::SmtpTransport::relay(&cfg.smtp_host) + .with_context(|| format!("SMTP 服务器不可用: {}", cfg.smtp_host))? + .port(cfg.smtp_port) + .credentials(creds) + .build(); + mailer + .send(&email) + .with_context(|| format!("发送验证码到 {to} 失败"))?; + log::info!("验证码邮件已发送: {to}"); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index a358d19..2cd7c80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod dao; mod db; mod gateway; mod gift; +mod mail; mod protocol; mod router; mod routes; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 8839808..0566a25 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -127,7 +127,7 @@ pub struct LoginReq { pub password: String, } -/// POST /api/auth/login:email+password → 200 {api_key} / 401 +/// POST /api/auth/login:email+password → 200 {api_key} / 401 / 403(未验证邮箱) pub async fn login( State(st): State, Json(req): Json, @@ -135,6 +135,12 @@ pub async fn login( let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; let user_id = dao::verify_user_password(&conn, &req.email, &req.password).map_err(|_| unauthorized())?; + if !dao::user_verified(&conn, user_id) { + return Err(( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "邮箱未验证,请先输入验证码完成验证" })), + )); + } let api_key = dao::get_or_create_api_key(&conn, user_id).map_err(internal)?; Ok(Json(serde_json::json!({ "api_key": api_key, @@ -142,6 +148,169 @@ pub async fn login( }))) } +#[derive(Deserialize)] +pub struct RegisterReq { + #[serde(default)] + pub name: String, + pub email: String, + pub password: String, +} + +/// 生成 6 位数字验证码 +fn gen_verification_code() -> String { + use rand::Rng; + format!("{:06}", rand::thread_rng().gen_range(0..1_000_000u32)) +} + +/// 发送验证码(dev 模式打日志;SMTP 模式发信),返回是否 dev 模式 + 验证码 +fn send_code(st: &AppState, email: &str) -> Result<(bool, String), ApiErr> { + let code = gen_verification_code(); + let hash = sha2_hex(&code); + let expires_at = "+10 minutes".to_string(); + { + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + dao::store_verification_code(&conn, email, &hash, &expires_at).map_err(internal)?; + } + // 过期时间用 SQLite 表达式写入(datetime('now', '+10 minutes')) + { + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + conn.execute( + "UPDATE email_verifications SET expires_at = datetime('now', '+10 minutes') WHERE email = ?1", + [email], + ) + .map_err(internal)?; + } + let dev = !st.cfg.mail.configured(); + crate::mail::send_verification_code(&st.cfg.mail, email, &code).map_err(internal)?; + Ok((dev, code)) +} + +fn sha2_hex(s: &str) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(s.as_bytes()); + hex::encode(h.finalize()) +} + +/// POST /api/auth/register:body {name?, email, password}。校验失败 400(邮箱格式 / 密码长度)、 +/// email 已注册 409;成功创建未验证用户并发 6 位验证码(10 分钟有效)→ 201 +/// {id, email, name, role, verified},dev 模式(未配置 SMTP)附 dev_code 便于本地测试。 +pub async fn register( + State(st): State, + Json(req): Json, +) -> Result<(StatusCode, Json), ApiErr> { + let email = req.email.trim().to_lowercase(); + // 邮箱格式 + let valid = email.contains('@') + && email.split('@').count() == 2 + && !email.split('@').next().unwrap_or("").is_empty() + && email.split('@').nth(1).unwrap_or("").contains('.'); + if !valid { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "邮箱格式不正确" })), + )); + } + if req.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"))?; + if dao::email_taken(&conn, &email) { + return Err(( + StatusCode::CONFLICT, + Json(serde_json::json!({ "error": "该邮箱已注册" })), + )); + } + let name = if req.name.trim().is_empty() { + email.split('@').next().unwrap_or("用户").to_string() + } else { + req.name.trim().to_string() + }; + let hash = crate::auth::hash_password(&req.password).map_err(internal)?; + let user_id = dao::create_unverified_user(&conn, &email, &name, &hash).map_err(internal)?; + drop(conn); + let (dev, code) = send_code(&st, &email)?; + let mut v = serde_json::json!({ + "id": user_id, + "email": email, + "name": name, + "role": "user", + "verified": false, + }); + if dev { + // dev 模式:验证码直接返回响应便于本地测试(生产配置 SMTP 后不返回) + v["dev_code"] = serde_json::json!(code); + } + Ok((StatusCode::CREATED, Json(v))) +} + +#[derive(Deserialize)] +pub struct VerifyReq { + pub email: String, + pub code: String, +} + +#[derive(Deserialize)] +pub struct ResendReq { + pub email: String, +} + +/// POST /api/auth/verify:{email, code} → 校验 6 位验证码 → 激活用户 + 建 quotas → 200 +/// 错误码累计 5 次失效;过期 → 400 需重发 +pub async fn verify( + State(st): State, + Json(req): Json, +) -> Result, ApiErr> { + let email = req.email.trim().to_lowercase(); + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let Some((hash, _attempts)) = dao::find_valid_verification(&conn, &email) else { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "验证码不存在或已过期,请重新获取" })), + )); + }; + if sha2_hex(req.code.trim()) != hash { + if dao::bump_verification_attempt(&conn, &email).map_err(internal)? { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "验证码错误次数过多,请重新获取" })), + )); + } + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "验证码错误" })), + )); + } + dao::activate_user(&conn, &email).map_err(internal)?; + dao::clear_verification(&conn, &email).map_err(internal)?; + Ok(Json(serde_json::json!({ "status": "ok", "email": email }))) +} + +/// POST /api/auth/resend-code:{email} → 60 秒限频 → 重新生成并发送验证码 +pub async fn resend_code( + State(st): State, + Json(req): Json, +) -> Result, ApiErr> { + let email = req.email.trim().to_lowercase(); + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + if dao::resend_too_soon(&conn, &email) { + return Err(( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({ "error": "请求过于频繁,请 60 秒后重试" })), + )); + } + drop(conn); + let (dev, code) = send_code(&st, &email)?; + let mut v = serde_json::json!({ "status": "ok", "email": email }); + if dev { + v["dev_code"] = serde_json::json!(code); + } + Ok(Json(v)) +} + /// GET /api/me:当前登录用户信息(P2-A 前端会话)→ {id, email, name, role} pub async fn me( State(st): State, @@ -208,6 +377,9 @@ pub fn router() -> Router { .route("/healthz", get(healthz)) .route("/api/auth/login", post(login)) .route("/api/auth/change-password", post(change_password)) + .route("/api/auth/register", post(register)) + .route("/api/auth/verify", post(verify)) + .route("/api/auth/resend-code", post(resend_code)) .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)) @@ -696,6 +868,209 @@ mod tests { assert_eq!(s, StatusCode::UNAUTHORIZED, "未认证应 401"); } + /* ---- 注册 / 邮箱验证(rant 2026-08-19T14:36:19 方案 B)---- */ + + #[tokio::test] + async fn register_verify_login_full_flow() { + // 注册(dev 模式:响应带 dev_code)→ 验证 → 登录 → 每日赠送 1 点 + let st = test_state("reg"); + let (s, body) = post( + st.clone(), + "/api/auth/register", + r#"{"name":"新用户","email":"newbie@example.com","password":"pass1234"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::CREATED, "注册应 201: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["email"], "newbie@example.com"); + assert_eq!(v["role"], "user"); + assert_eq!(v["verified"], false); + let code = v["dev_code"].as_str().expect("dev 模式返回验证码"); + // 未验证登录 → 403 + let (s, body) = post( + st.clone(), + "/api/auth/login", + r#"{"email":"newbie@example.com","password":"pass1234"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::FORBIDDEN, "未验证登录应 403: {body}"); + // 验证 + let (s, body) = post( + st.clone(), + "/api/auth/verify", + &format!(r#"{{"email":"newbie@example.com","code":"{code}"}}"#), + None, + ) + .await; + assert_eq!(s, StatusCode::OK, "验证应 200: {body}"); + // 验证后登录 → 200 + let (s, body) = post( + st.clone(), + "/api/auth/login", + r#"{"email":"newbie@example.com","password":"pass1234"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::OK, "验证后登录应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let bearer = v["api_key"].as_str().unwrap().to_string(); + // 钱包:余额 0 + 每日赠送 1 点生效(P1 懒加载) + let (s, body) = get(st.clone(), "/api/wallet", Some(&bearer)).await; + assert_eq!(s, StatusCode::OK, "wallet 应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["balance"], 0.0, "永久余额 0: {body}"); + assert_eq!(v["gift_balance"], 1.0, "每日赠送 1 点: {body}"); + } + + #[tokio::test] + async fn register_duplicate_409() { + let st = test_state("regdup"); + post( + st.clone(), + "/api/auth/register", + r#"{"email":"dup@example.com","password":"pass1234"}"#, + None, + ) + .await; + let (s, body) = post( + st, + "/api/auth/register", + r#"{"email":"dup@example.com","password":"other123"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::CONFLICT, "重复邮箱应 409: {body}"); + } + + #[tokio::test] + async fn register_invalid_input_400() { + let st = test_state("reg400"); + // 非法邮箱 + let (s, _) = post( + st.clone(), + "/api/auth/register", + r#"{"email":"not-an-email","password":"pass1234"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST, "非法邮箱应 400"); + // 弱密码 + let (s, _) = post( + st.clone(), + "/api/auth/register", + r#"{"email":"ok@example.com","password":"short"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST, "弱密码应 400"); + } + + #[tokio::test] + async fn verify_wrong_code_expires_and_attempt_limit() { + let st = test_state("regverr"); + let (_, body) = post( + st.clone(), + "/api/auth/register", + r#"{"email":"verr@example.com","password":"pass1234"}"#, + None, + ) + .await; + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let code = v["dev_code"].as_str().unwrap(); + // 错误码 ×4 → 400 + for _ in 0..4 { + let (s, _) = post( + st.clone(), + "/api/auth/verify", + r#"{"email":"verr@example.com","code":"000000"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + } + // 第 5 次错误 → 达到上限,记录删除 + let (s, body) = post( + st.clone(), + "/api/auth/verify", + r#"{"email":"verr@example.com","code":"000000"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST, "第 5 次错误应 400: {body}"); + assert!(body.contains("次数过多"), "提示次数过多: {body}"); + // 重发 → 新码 → 验证成功 + let (s, body) = post( + st.clone(), + "/api/auth/resend-code", + r#"{"email":"verr@example.com"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::OK, "重发应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let code2 = v["dev_code"].as_str().unwrap(); + assert_ne!(code, code2, "重发生成新码"); + let (s, _) = post( + st.clone(), + "/api/auth/verify", + &format!(r#"{{"email":"verr@example.com","code":"{code2}"}}"#), + None, + ) + .await; + assert_eq!(s, StatusCode::OK, "新码验证应 200"); + // 过期场景:直接改 expires_at 为过去 + let (_, body) = post( + st.clone(), + "/api/auth/register", + r#"{"email":"expired@example.com","password":"pass1234"}"#, + None, + ) + .await; + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let code3 = v["dev_code"].as_str().unwrap(); + { + let conn = st.db.lock().unwrap(); + conn.execute( + "UPDATE email_verifications SET expires_at = datetime('now', '-1 minute') WHERE email = 'expired@example.com'", + [], + ) + .unwrap(); + } + let (s, body) = post( + st, + "/api/auth/verify", + &format!(r#"{{"email":"expired@example.com","code":"{code3}"}}"#), + None, + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST, "过期应 400: {body}"); + assert!(body.contains("过期"), "提示过期: {body}"); + } + + #[tokio::test] + async fn resend_code_rate_limited() { + let st = test_state("regrl"); + let (_, body) = post( + st.clone(), + "/api/auth/register", + r#"{"email":"rl@example.com","password":"pass1234"}"#, + None, + ) + .await; + assert!(serde_json::from_str::(&body).unwrap()["dev_code"].is_string()); + // 60 秒内重发 → 429 + let (s, body) = post( + st, + "/api/auth/resend-code", + r#"{"email":"rl@example.com"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::TOO_MANY_REQUESTS, "限频应 429: {body}"); + } + /* ---- P2-C:部门管理 / 加额审批 / 运营者 / 用量三组聚合 ---- */ #[tokio::test] diff --git a/ui/css/style.css b/ui/css/style.css index de0123a..1361468 100644 --- a/ui/css/style.css +++ b/ui/css/style.css @@ -222,6 +222,17 @@ select.th-filter:disabled { opacity: .55; cursor: not-allowed; } select.input.input-error { border-color: var(--danger); } select.input.input-error:focus { border-color: var(--danger); } select.input option { background: var(--bg-card); } +/* 登录卡内链接式按钮(注册/重发验证码,rant 2026-08-19T14:36:19) */ +.linklike { + background: none; + border: none; + padding: 0; + color: var(--accent, #2a9d8f); + cursor: pointer; + font: inherit; + text-decoration: underline; +} +.linklike:hover { opacity: 0.85; } .card { background: var(--bg-card); diff --git a/ui/index.html b/ui/index.html index a26c7b0..ef8d11e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -33,8 +33,40 @@

AITokenPool

- - + + + + + + + diff --git a/ui/js/app.js b/ui/js/app.js index 9f9fa10..d10ee97 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -2374,6 +2374,130 @@ toast(T("logout.done"), "info"); }); + /* ---- 注册 / 邮箱验证(rant 2026-08-19T14:36:19:自助注册)---- */ + const regLink = $("#reg-link"); + const regBack = $("#reg-back"); + const verifyBack = $("#verify-back"); + const loginFormEl = $("#login-form"); + const registerFormEl = $("#register-form"); + const verifyFormEl = $("#verify-form"); + + function showAuthForm(which) { + // which: "login" | "register" | "verify" + loginFormEl.classList.toggle("hidden", which !== "login"); + registerFormEl.classList.toggle("hidden", which !== "register"); + verifyFormEl.classList.toggle("hidden", which !== "verify"); + } + + if (regLink) { + regLink.addEventListener("click", (e) => { + e.preventDefault(); + showAuthForm("register"); + $("#reg-email").focus(); + }); + } + if (regBack) { + regBack.addEventListener("click", (e) => { + e.preventDefault(); + showAuthForm("login"); + }); + } + if (verifyBack) { + verifyBack.addEventListener("click", (e) => { + e.preventDefault(); + showAuthForm("login"); + }); + } + + // 注册提交 + registerFormEl.addEventListener("submit", async (e) => { + e.preventDefault(); + const email = $("#reg-email").value.trim(); + const name = $("#reg-name").value.trim(); + const pw = $("#reg-pass").value; + const pw2 = $("#reg-pass2").value; + let firstErr = null; + const errEl = $("#reg-error"); + const showErr = (msg) => { errEl.textContent = msg; errEl.hidden = false; }; + const hideErr = () => { errEl.hidden = true; errEl.textContent = ""; }; + if (!email) { setFieldError($("#reg-email"), T("login.err.email")); firstErr = firstErr || $("#reg-email"); } + else clearFieldError($("#reg-email")); + if (!pw) { setFieldError($("#reg-pass"), T("register.err.pass")); firstErr = firstErr || $("#reg-pass"); } + else clearFieldError($("#reg-pass")); + if (pw !== pw2) { setFieldError($("#reg-pass2"), T("register.err.confirm")); firstErr = firstErr || $("#reg-pass2"); } + else clearFieldError($("#reg-pass2")); + if (firstErr) { firstErr.focus(); return; } + hideErr(); + const btn = registerFormEl.querySelector('button[type="submit"]'); + if (btn) { btn.disabled = true; } + try { + const r = await api.post("/api/auth/register", { name, email, password: pw }); + // 成功 → 切到验证码页(dev 模式响应带 dev_code,提示到控制台/日志) + $("#verify-email").value = email; + showAuthForm("verify"); + if (r.dev_code) { + toast(T("verify.devCode") + ": " + r.dev_code, "info"); + } else { + toast(T("verify.sent"), "success"); + } + $("#verify-code").focus(); + } catch (err) { + if (err && err.status === 409) { + setFieldError($("#reg-email"), T("register.err.taken")); + } else { + const m = (err && err.message) ? I18n.mapErr(err.message) : T("register.err.fail"); + showErr(m); + } + } finally { + if (btn) { btn.disabled = false; } + } + }); + + // 验证提交 + verifyFormEl.addEventListener("submit", async (e) => { + e.preventDefault(); + const email = $("#verify-email").value.trim(); + const code = $("#verify-code").value.trim(); + const errEl = $("#verify-error"); + const showErr = (msg) => { errEl.textContent = msg; errEl.hidden = false; }; + const hideErr = () => { errEl.hidden = true; errEl.textContent = ""; }; + if (!code) { setFieldError($("#verify-code"), T("verify.err.code")); $("#verify-code").focus(); return; } + clearFieldError($("#verify-code")); + hideErr(); + const btn = verifyFormEl.querySelector('button[type="submit"]'); + if (btn) { btn.disabled = true; } + try { + await api.post("/api/auth/verify", { email, code }); + toast(T("verify.ok"), "success"); + showAuthForm("login"); + $("#login-email").value = email; + $("#login-email").focus(); + } catch (err) { + const m = (err && err.message) ? I18n.mapErr(err.message) : T("verify.err.fail"); + showErr(m); + } finally { + if (btn) { btn.disabled = false; } + } + }); + + // 重发验证码 + $("#resend-btn").addEventListener("click", async () => { + const email = $("#verify-email").value.trim(); + if (!email) return; + const b = $("#resend-btn"); + b.disabled = true; + try { + const r = await api.post("/api/auth/resend-code", { email }); + if (r.dev_code) { toast(T("verify.devCode") + ": " + r.dev_code, "info"); } + else { toast(T("verify.sent"), "success"); } + } catch (err) { + const m = (err && err.message) ? I18n.mapErr(err.message) : T("verify.err.fail"); + toast(m, "error"); + } finally { + setTimeout(() => { b.disabled = false; }, 1000); + } + }); + // 市场筛选(搜索防抖 ~150ms + 高亮 + 清空按钮,rant 18:06:09 D) wireSearch($("#mk-search"), renderMarketplace); $("#mk-provider").addEventListener("change", renderMarketplace); diff --git a/ui/js/i18n.js b/ui/js/i18n.js index b352d5d..df141a9 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -51,6 +51,25 @@ "login.err.bad": "邮箱或密码错误", "login.err.fail": "登录失败,请稍后重试", "login.welcome": "欢迎回来,{name}", + "register.name": "昵称(可选)", + "register.name.ph": "给自己起个昵称", + "register.password": "密码", + "register.confirm": "确认密码", + "register.submit": "注 册", + "register.back": "返回登录", + "register.err.pass": "请输入密码", + "register.err.confirm": "两次输入的密码不一致", + "register.err.taken": "该邮箱已注册", + "register.err.fail": "注册失败,请稍后重试", + "verify.hint": "验证码已发送到邮箱,请输入 6 位验证码(10 分钟内有效)", + "verify.code": "验证码", + "verify.submit": "验证并激活", + "verify.resend": "重新发送", + "verify.sent": "验证码已发送到邮箱", + "verify.ok": "注册成功,请登录", + "verify.devCode": "[dev] 验证码", + "verify.err.code": "请输入 6 位验证码", + "verify.err.fail": "验证失败,请重试", "login.session.fail": "会话恢复失败,请重新登录", "login.session.expired": "登录已过期,请重新登录", "login.balance.fail": "余额加载失败,显示 0", @@ -609,6 +628,13 @@ "err.serverError": "服务器内部错误", "err.network": "网络不可用,请检查后端服务是否启动", "err.loadFail": "数据加载失败,请检查后端服务", + "err.emailTaken": "该邮箱已注册", + "err.emailUnverified": "邮箱未验证,请先输入验证码完成验证", + "err.badCode": "验证码错误", + "err.codeExpired": "验证码不存在或已过期,请重新获取", + "err.codeLocked": "验证码错误次数过多,请重新获取", + "err.badEmail": "邮箱格式不正确", + "err.weakPassword": "密码至少 8 位", }; var EN = { @@ -652,6 +678,25 @@ "login.session.fail": "Session restore failed, please sign in again", "login.session.expired": "Session expired, please sign in again", "login.balance.fail": "Balance load failed, showing 0", + "register.name": "Nickname (optional)", + "register.name.ph": "Pick a nickname", + "register.password": "Password", + "register.confirm": "Confirm password", + "register.submit": "Sign Up", + "register.back": "Back to login", + "register.err.pass": "Please enter a password", + "register.err.confirm": "Passwords do not match", + "register.err.taken": "This email is already registered", + "register.err.fail": "Registration failed, please try again later", + "verify.hint": "A verification code was sent to your email. Enter the 6-digit code (valid for 10 minutes)", + "verify.code": "Verification code", + "verify.submit": "Verify & Activate", + "verify.resend": "Resend", + "verify.sent": "Verification code sent to your email", + "verify.ok": "Registered successfully, please sign in", + "verify.devCode": "[dev] Code", + "verify.err.code": "Please enter the 6-digit code", + "verify.err.fail": "Verification failed, please try again", "view.dashboard": "Dashboard", "view.marketplace": "Marketplace", @@ -1189,6 +1234,13 @@ "err.serverError": "Internal server error", "err.network": "Network unavailable, please check if the backend service is running", "err.loadFail": "Failed to load data, please check the backend service", + "err.emailTaken": "This email is already registered", + "err.emailUnverified": "Email not verified, please enter the verification code", + "err.badCode": "Incorrect verification code", + "err.codeExpired": "Code invalid or expired, please request a new one", + "err.codeLocked": "Too many wrong attempts, please request a new code", + "err.badEmail": "Invalid email format", + "err.weakPassword": "Password must be at least 8 characters", }; // 后端中文错误 → en 键(lang=en 时对已知错误做映射;未知原样返回) @@ -1208,6 +1260,13 @@ ["不存在", "err.notFound"], ["服务器内部错误", "err.serverError"], ["内部错误", "err.serverError"], + ["该邮箱已注册", "err.emailTaken"], + ["邮箱未验证", "err.emailUnverified"], + ["验证码错误", "err.badCode"], + ["验证码不存在或已过期", "err.codeExpired"], + ["验证码错误次数过多", "err.codeLocked"], + ["邮箱格式不正确", "err.badEmail"], + ["密码至少 8 位", "err.weakPassword"], ]; var current = "zh";