feat(auth): issue/verify username-bearing HS256 JWT

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cc 2026-06-24 20:57:37 +08:00
parent 42639856b2
commit e1820d0ec8
2 changed files with 99 additions and 15 deletions

View File

@ -13,9 +13,10 @@
//! 口令哈希;若将来要对外网开放,应换 Argon2/bcrypt 这类慢哈希。) //! 口令哈希;若将来要对外网开放,应换 Argon2/bcrypt 这类慢哈希。)
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use sqlx::mysql::{MySqlPool, MySqlPoolOptions}; use sqlx::mysql::{MySqlPool, MySqlPoolOptions};
@ -187,19 +188,65 @@ impl UserStore {
} }
} }
/// 签发一个登录 token。 /// JWT 载荷:`sub` 为用户名,`exp` 为到期 Unix 秒。
/// #[derive(Serialize, Deserialize)]
/// 不引入 `rand`/`uuid`:用「纳秒时间戳 + 进程内单调计数器」拼出唯一输入, struct Claims {
/// 再 SHA256 成不可预测的 hex。够当登录态标识用将来要做真正的会话校验 sub: String,
/// 应换成签名 token如 JWT或服务端会话表。 exp: usize,
pub fn issue_token() -> String { }
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now() /// 签发登录 token带用户名的 HS256 JWT30 天过期。密钥取 `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<String> {
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<String> {
decode::<Claims>(
token,
&DecodingKey::from_secret(secret),
&Validation::default(),
)
.ok()
.map(|data| data.claims.sub)
}
/// 从 `JWT_SECRET` 取签名密钥;未设置则用内置开发密钥并告警(生产务必设置)。
fn jwt_secret() -> Vec<u8> {
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) .duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0) as usize
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
sha256_hex(&format!("{nanos}-{n}"))
} }
/// 计算 SHA256 并返回小写 hex 字符串。 /// 计算 SHA256 并返回小写 hex 字符串。
@ -224,3 +271,40 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
} }
diff == 0 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
}
}

View File

@ -98,8 +98,8 @@ async fn login(
if state.users.verify(&req.username, &req.password).await { if state.users.verify(&req.username, &req.password).await {
Ok(Json(LoginResponse { Ok(Json(LoginResponse {
ok: true, ok: true,
username: req.username, username: req.username.clone(),
token: crate::auth::issue_token(), token: crate::auth::issue_token(&req.username),
})) }))
} else { } else {
Err(StatusCode::UNAUTHORIZED) Err(StatusCode::UNAUTHORIZED)