PersonalWebApplication/Server/src/auth.rs
cc e5b27a6281 refactor(auth): 去掉登录/入口门的内置兜底,强制走数据库
- db_pool_from_env 强制要求 DATABASE_URL,缺失即 panic 拒启
- UserStore/WallUserStore/DevGate 改为只持连接池的 struct,
  删除全部 seed()/Memory/dev_gate.txt 文件兜底——源码不再留写死账号/hash
- 本地新增 MariaDB(InternetProject 库 + ccuser/dev_gate/wall_users 三表)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:09:18 +08:00

265 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 用户存储与登录校验 + 开发者入口门。
//!
//! 两者默认都走 **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
}
}