PersonalWebApplication/Server/src/auth.rs

265 lines
9.1 KiB
Rust
Raw Normal View History

//! 用户存储与登录校验 + 开发者入口门。
//!
//! 两者默认都走 **MySQL**,且**每次校验直接查库(走 IO不在内存里缓存整表**——
//! 机器内存紧张,宁可每次打一次很轻的索引查询,也不常驻一份副本:
//! - 登录:表 `ccuser(username, password_sha256)`
//! - 入口门:表 `dev_gate(gate_hash)`。
//! 连接串由环境变量 `DATABASE_URL` 指定,`UserStore`/`WallUserStore`/`DevGate` **共用
//! 同一个连接池**(见 `db_pool_from_env`),避免开多份连接。**强制要求 `DATABASE_URL`**
//! 未设置则启动即 panic——不再有任何写死/文件兜底,登录与入口门一律走数据库。
//!
//! 口令永不明文存储:库里存的是 SHA256(hex),登录时把传入明文同样
//! 哈希后做**等长比较**。注意SHA256 仅用于“不落明文”,并非抗暴力破解的
//! 口令哈希;若将来要对外网开放,应换 Argon2/bcrypt 这类慢哈希。)
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};
/// 按 `DATABASE_URL` 建一个**懒连接**池(启动不阻塞,首次查询才真正建连),
/// 供 `UserStore`/`WallUserStore`/`DevGate` 共用。
///
/// **强制要求 `DATABASE_URL`**:未设置则直接 panic 拒绝启动——不再有任何写死/文件兜底,
/// 所有登录与入口门一律走数据库。
pub fn db_pool_from_env() -> MySqlPool {
let url = std::env::var("DATABASE_URL")
.ok()
.filter(|u| !u.trim().is_empty())
.expect("未设置 DATABASE_URL登录与入口门已无兜底必须配置数据库连接串后再启动");
MySqlPoolOptions::new()
.max_connections(5)
.connect_lazy(&url)
.expect("DATABASE_URL 格式非法")
}
/// 开发者「入口门」:一组合法的入口 hash。
///
/// 访问 `域名/#<hash>` 时,前端把 `<hash>` 发给后端,只有命中这里的某一个,
/// 才放行去显示登录页。这样合法 hash **只存在后端**,不进前端打包产物,
/// 扒前端 JS 也看不到;且每个开发者一串、可单独增删。
///
/// 注意:入口 hash 只是“藏门”的共享口令,**不是身份凭证**。真正证明身份仍靠
/// 登录(用户名 + 密码,见 `UserStore`)。
pub struct DevGate {
/// 每次校验查 MySQL `dev_gate` 表(不缓存整表)。
pool: MySqlPool,
}
impl DevGate {
/// 入口门一律走 MySQL`dev_gate` 表),无任何文件/写死兜底。
pub fn new(pool: MySqlPool) -> Self {
eprintln!("[gate] 入口校验走 MySQL");
DevGate { pool }
}
/// 该 hash 是否属于某个合法开发者。每次查库(命中即放行);
/// 查询出错按“不放行”处理,不泄露后端内部状态。
pub async fn allows(&self, hash: &str) -> bool {
let row: Result<Option<(String,)>, sqlx::Error> =
sqlx::query_as("SELECT gate_hash FROM dev_gate WHERE gate_hash = ?")
.bind(hash)
.fetch_optional(&self.pool)
.await;
match row {
Ok(found) => found.is_some(),
Err(e) => {
eprintln!("[gate] 查询 dev_gate 失败: {e}");
false
}
}
}
}
/// 用户校验后端:一律走 MySQL `ccuser` 表,无写死兜底。
pub struct UserStore {
pool: MySqlPool,
}
impl UserStore {
/// 每次校验查 `ccuser`,无任何内置兜底。
pub fn new(pool: MySqlPool) -> Self {
eprintln!("[auth] 用户校验走 MySQL");
UserStore { pool }
}
/// 校验用户名 + 明文密码是否匹配。
///
/// 用户名不存在、密码错误、乃至 DB 查询出错,都一律返回 `false`
/// 不向外区分(避免泄露“某用户名是否存在”或后端内部状态)。
pub async fn verify(&self, username: &str, password: &str) -> bool {
let actual_hex = sha256_hex(password);
let row: Result<Option<(String,)>, sqlx::Error> =
sqlx::query_as("SELECT password_sha256 FROM ccuser WHERE username = ?")
.bind(username)
.fetch_optional(&self.pool)
.await;
let expected_hex = match row {
Ok(found) => found.map(|(hex,)| hex),
Err(e) => {
eprintln!("[auth] 查询 ccuser 失败: {e}");
None
}
};
match expected_hex {
Some(expected) => constant_time_eq(actual_hex.as_bytes(), expected.as_bytes()),
None => false,
}
}
}
/// 照片墙用户校验后端:一律走 MySQL `wall_users` 表(与 `ccuser` 物理隔离),无写死兜底。
pub struct WallUserStore {
pool: MySqlPool,
}
impl WallUserStore {
pub fn new(pool: MySqlPool) -> Self {
eprintln!("[auth] 照片墙用户校验走 MySQL (wall_users)");
WallUserStore { pool }
}
pub async fn verify(&self, username: &str, password: &str) -> bool {
let actual_hex = sha256_hex(password);
let row: Result<Option<(String,)>, sqlx::Error> =
sqlx::query_as("SELECT password_sha256 FROM wall_users WHERE username = ?")
.bind(username)
.fetch_optional(&self.pool)
.await;
let expected_hex = match row {
Ok(found) => found.map(|(hex,)| hex),
Err(e) => {
eprintln!("[auth] 查询 wall_users 失败: {e}");
None
}
};
match expected_hex {
Some(expected) => constant_time_eq(actual_hex.as_bytes(), expected.as_bytes()),
None => false,
}
}
}
/// JWT 载荷:`sub` 为用户名,`exp` 为到期 Unix 秒。
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
/// 签发登录 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),
)
.expect("JWT 编码失败")
}
/// 用指定密钥校验 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)
.map(|d| d.as_secs())
.unwrap_or(0) as usize
}
/// 计算 SHA256 并返回小写 hex 字符串。
fn sha256_hex(input: &str) -> String {
let digest = Sha256::digest(input.as_bytes());
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
/// 等长定值时间比较,避免按字节短路造成的时序侧信道。
/// 两串长度不同直接判否hex 定长 64正常等长
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
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
}
}