From e1820d0ec83a9812b97ba23c7a7300f26f96b576 Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 24 Jun 2026 20:57:37 +0800 Subject: [PATCH] feat(auth): issue/verify username-bearing HS256 JWT Co-Authored-By: Claude Opus 4.8 --- Server/src/auth.rs | 110 ++++++++++++++++++++++++++++++++++----- Server/src/routes/web.rs | 4 +- 2 files changed, 99 insertions(+), 15 deletions(-) diff --git a/Server/src/auth.rs b/Server/src/auth.rs index dab2995..7d0ff90 100644 --- a/Server/src/auth.rs +++ b/Server/src/auth.rs @@ -13,9 +13,10 @@ //! 口令哈希;若将来要对外网开放,应换 Argon2/bcrypt 这类慢哈希。) use std::collections::{HashMap, HashSet}; -use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::mysql::{MySqlPool, MySqlPoolOptions}; @@ -187,19 +188,65 @@ impl UserStore { } } -/// 签发一个登录 token。 -/// -/// 不引入 `rand`/`uuid`:用「纳秒时间戳 + 进程内单调计数器」拼出唯一输入, -/// 再 SHA256 成不可预测的 hex。够当登录态标识用;将来要做真正的会话校验, -/// 应换成签名 token(如 JWT)或服务端会话表。 -pub fn issue_token() -> String { - static COUNTER: AtomicU64 = AtomicU64::new(0); - let nanos = SystemTime::now() +/// JWT 载荷:`sub` 为用户名,`exp` 为到期 Unix 秒。 +#[derive(Serialize, Deserialize)] +struct Claims { + sub: String, + exp: usize, +} + +/// 签发登录 token:带用户名的 HS256 JWT,30 天过期。密钥取 `JWT_SECRET`。 +pub fn issue_token(username: &str) -> String { + let exp = now_secs() + 30 * 24 * 3600; + sign(&jwt_secret(), username, exp) +} + +/// 校验 token,成功返回其中的用户名。过期/签名不符/格式错均返回 `None`。 +pub fn verify_token(token: &str) -> Option { + verify(&jwt_secret(), token) +} + +/// 用指定密钥签一个 JWT(便于测试,不读环境变量)。 +fn sign(secret: &[u8], username: &str, exp: usize) -> String { + let claims = Claims { + sub: username.to_string(), + exp, + }; + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret), + ) + .unwrap_or_default() +} + +/// 用指定密钥校验 JWT(便于测试)。默认校验 `exp`。 +fn verify(secret: &[u8], token: &str) -> Option { + decode::( + token, + &DecodingKey::from_secret(secret), + &Validation::default(), + ) + .ok() + .map(|data| data.claims.sub) +} + +/// 从 `JWT_SECRET` 取签名密钥;未设置则用内置开发密钥并告警(生产务必设置)。 +fn jwt_secret() -> Vec { + match std::env::var("JWT_SECRET") { + Ok(s) if !s.trim().is_empty() => s.into_bytes(), + _ => { + eprintln!("[auth] 警告:未设置 JWT_SECRET,使用内置开发密钥(生产环境务必设置)"); + b"dev-insecure-secret-change-me".to_vec() + } + } +} + +fn now_secs() -> usize { + SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - let n = COUNTER.fetch_add(1, Ordering::Relaxed); - sha256_hex(&format!("{nanos}-{n}")) + .map(|d| d.as_secs()) + .unwrap_or(0) as usize } /// 计算 SHA256 并返回小写 hex 字符串。 @@ -224,3 +271,40 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { } diff == 0 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jwt_round_trips_username() { + let secret = b"test-secret"; + let token = sign(secret, "cc", far_future()); + assert_eq!(verify(secret, &token), Some("cc".to_string())); + } + + #[test] + fn jwt_rejects_wrong_secret() { + let token = sign(b"secret-a", "cc", far_future()); + assert_eq!(verify(b"secret-b", &token), None); + } + + #[test] + fn jwt_rejects_garbage() { + assert_eq!(verify(b"s", "not-a-jwt"), None); + } + + #[test] + fn jwt_rejects_expired() { + let token = sign(b"s", "cc", 1); // 1970 年过期 + assert_eq!(verify(b"s", &token), None); + } + + fn far_future() -> usize { + (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600) as usize + } +} diff --git a/Server/src/routes/web.rs b/Server/src/routes/web.rs index 71004cf..0d89db2 100644 --- a/Server/src/routes/web.rs +++ b/Server/src/routes/web.rs @@ -98,8 +98,8 @@ async fn login( if state.users.verify(&req.username, &req.password).await { Ok(Json(LoginResponse { ok: true, - username: req.username, - token: crate::auth::issue_token(), + username: req.username.clone(), + token: crate::auth::issue_token(&req.username), })) } else { Err(StatusCode::UNAUTHORIZED)