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>
This commit is contained in:
cc 2026-06-25 21:09:18 +08:00
parent fdc2f8b94b
commit e5b27a6281
2 changed files with 68 additions and 181 deletions

View File

@ -4,15 +4,14 @@
//! 机器内存紧张,宁可每次打一次很轻的索引查询,也不常驻一份副本: //! 机器内存紧张,宁可每次打一次很轻的索引查询,也不常驻一份副本:
//! - 登录:表 `ccuser(username, password_sha256)` //! - 登录:表 `ccuser(username, password_sha256)`
//! - 入口门:表 `dev_gate(gate_hash)`。 //! - 入口门:表 `dev_gate(gate_hash)`。
//! 连接串由环境变量 `DATABASE_URL` 指定,`UserStore` 与 `DevGate` **共用同一个 //! 连接串由环境变量 `DATABASE_URL` 指定,`UserStore`/`WallUserStore`/`DevGate` **共用
//! 连接池**(见 `db_pool_from_env`),避免开两份连接。未设置 `DATABASE_URL` 时各自 //! 同一个连接池**(见 `db_pool_from_env`),避免开多份连接。**强制要求 `DATABASE_URL`**
//! 回退到内置/文件兜底,方便本地无 DB 时开发 //! 未设置则启动即 panic——不再有任何写死/文件兜底,登录与入口门一律走数据库
//! //!
//! 口令永不明文存储:库里存的是 SHA256(hex),登录时把传入明文同样 //! 口令永不明文存储:库里存的是 SHA256(hex),登录时把传入明文同样
//! 哈希后做**等长比较**。注意SHA256 仅用于“不落明文”,并非抗暴力破解的 //! 哈希后做**等长比较**。注意SHA256 仅用于“不落明文”,并非抗暴力破解的
//! 口令哈希;若将来要对外网开放,应换 Argon2/bcrypt 这类慢哈希。) //! 口令哈希;若将来要对外网开放,应换 Argon2/bcrypt 这类慢哈希。)
use std::collections::{HashMap, HashSet};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
@ -21,18 +20,19 @@ use sha2::{Digest, Sha256};
use sqlx::mysql::{MySqlPool, MySqlPoolOptions}; use sqlx::mysql::{MySqlPool, MySqlPoolOptions};
/// 按 `DATABASE_URL` 建一个**懒连接**池(启动不阻塞,首次查询才真正建连), /// 按 `DATABASE_URL` 建一个**懒连接**池(启动不阻塞,首次查询才真正建连),
/// 供 `UserStore` 与 `DevGate` 共用。未设置该环境变量则返回 `None`,调用方各自兜底。 /// 供 `UserStore`/`WallUserStore`/`DevGate` 共用。
pub fn db_pool_from_env() -> Option<MySqlPool> { ///
match std::env::var("DATABASE_URL") { /// **强制要求 `DATABASE_URL`**:未设置则直接 panic 拒绝启动——不再有任何写死/文件兜底,
Ok(url) if !url.trim().is_empty() => { /// 所有登录与入口门一律走数据库。
let pool = MySqlPoolOptions::new() 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) .max_connections(5)
.connect_lazy(&url) .connect_lazy(&url)
.expect("DATABASE_URL 格式非法"); .expect("DATABASE_URL 格式非法")
Some(pool)
}
_ => None,
}
} }
/// 开发者「入口门」:一组合法的入口 hash。 /// 开发者「入口门」:一组合法的入口 hash。
@ -43,71 +43,25 @@ pub fn db_pool_from_env() -> Option<MySqlPool> {
/// ///
/// 注意:入口 hash 只是“藏门”的共享口令,**不是身份凭证**。真正证明身份仍靠 /// 注意:入口 hash 只是“藏门”的共享口令,**不是身份凭证**。真正证明身份仍靠
/// 登录(用户名 + 密码,见 `UserStore`)。 /// 登录(用户名 + 密码,见 `UserStore`)。
pub enum DevGate { pub struct DevGate {
/// 每次校验查 MySQL `dev_gate` 表(不缓存整表)。 /// 每次校验查 MySQL `dev_gate` 表(不缓存整表)。
Db(MySqlPool), pool: MySqlPool,
/// 内置/文件载入的 hash 集合,仅作无 DB 时的兜底。
Memory(HashSet<String>),
} }
impl DevGate { impl DevGate {
/// 有连接池就走 MySQL否则读 `GATE_FILE`(默认 `dev_gate.txt`),文件也没有再 `seed()`。 /// 入口门一律走 MySQL`dev_gate` 表),无任何文件/写死兜底。
pub fn new(pool: Option<MySqlPool>) -> Self { pub fn new(pool: MySqlPool) -> Self {
match pool {
Some(p) => {
eprintln!("[gate] 入口校验走 MySQL"); eprintln!("[gate] 入口校验走 MySQL");
DevGate::Db(p) DevGate { pool }
}
None => Self::load_file(),
}
} }
/// 从 `GATE_FILE`(默认 `dev_gate.txt`)读入口 hash读不到回退内置 `seed()`。 /// 该 hash 是否属于某个合法开发者。每次查库(命中即放行);
/// 文件格式:一行一个 hash`#` 之后为注释,空行忽略。
fn load_file() -> Self {
let path = std::env::var("GATE_FILE").unwrap_or_else(|_| "dev_gate.txt".to_string());
match std::fs::read_to_string(&path) {
Ok(text) => {
let hashes = Self::parse(&text);
eprintln!("[gate] 从 {path} 载入 {} 个入口 hash", hashes.len());
DevGate::Memory(hashes)
}
Err(_) => {
eprintln!("[gate] 未找到 {path},回退到内置写死列表");
Self::seed()
}
}
}
/// 把文件文本解析为 hash 集合:丢掉 `#` 注释与空行,每行取剩下的非空串。
fn parse(text: &str) -> HashSet<String> {
text.lines()
.map(|line| line.split('#').next().unwrap_or("").trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
/// 内置写死的入口 hash 表,作为无 DB / 无文件时的兜底。
pub fn seed() -> Self {
let mut hashes = HashSet::new();
// ccSHA256("192118Lht") 的前 12 位。对应入口 域名/#0086e83c9b10
hashes.insert("0086e83c9b10".to_string());
DevGate::Memory(hashes)
}
/// 该 hash 是否属于某个合法开发者。DB 模式下每次查库(命中即放行);
/// 查询出错按“不放行”处理,不泄露后端内部状态。 /// 查询出错按“不放行”处理,不泄露后端内部状态。
pub async fn allows(&self, hash: &str) -> bool { pub async fn allows(&self, hash: &str) -> bool {
match self {
DevGate::Memory(hashes) => hashes.contains(hash),
DevGate::Db(pool) => {
let row: Result<Option<(String,)>, sqlx::Error> = let row: Result<Option<(String,)>, sqlx::Error> =
sqlx::query_as("SELECT gate_hash FROM dev_gate WHERE gate_hash = ?") sqlx::query_as("SELECT gate_hash FROM dev_gate WHERE gate_hash = ?")
.bind(hash) .bind(hash)
.fetch_optional(pool) .fetch_optional(&self.pool)
.await; .await;
match row { match row {
Ok(found) => found.is_some(), Ok(found) => found.is_some(),
@ -117,45 +71,18 @@ impl DevGate {
} }
} }
} }
}
}
} }
/// 用户校验后端:优先连 MySQL无 `DATABASE_URL` 时退回内置写死表。 /// 用户校验后端:一律走 MySQL `ccuser` 表,无写死兜底。
pub enum UserStore { pub struct UserStore {
/// 从 MySQL 的 `ccuser` 表查 `password_sha256`。 pool: MySqlPool,
Db(MySqlPool),
/// 内置写死表(用户名 → 密码SHA256 hex仅作无 DB 时的兜底。
Memory(HashMap<String, String>),
} }
impl UserStore { impl UserStore {
/// 有连接池就走 MySQL每次校验查 `ccuser`);否则回退到内置 `seed()`。 /// 每次校验查 `ccuser`,无任何内置兜底。
pub fn new(pool: Option<MySqlPool>) -> Self { pub fn new(pool: MySqlPool) -> Self {
match pool {
Some(p) => {
eprintln!("[auth] 用户校验走 MySQL"); eprintln!("[auth] 用户校验走 MySQL");
UserStore::Db(p) UserStore { pool }
}
None => {
eprintln!("[auth] 未设置 DATABASE_URL回退到内置写死用户表");
Self::seed()
}
}
}
/// 内置写死的单用户表,作为 `load()` 无 DB 配置时的兜底。
pub fn seed() -> Self {
let mut users = HashMap::new();
// cc / 192118Lht
// 下面这串是 SHA256("192118Lht") 的 hex明文不进源码。
users.insert(
"cc".to_string(),
"0086e83c9b108b227eed55425e9641286f42bd0c31a1b95afbb9edd6d3aa6234".to_string(),
);
UserStore::Memory(users)
} }
/// 校验用户名 + 明文密码是否匹配。 /// 校验用户名 + 明文密码是否匹配。
@ -164,22 +91,17 @@ impl UserStore {
/// 不向外区分(避免泄露“某用户名是否存在”或后端内部状态)。 /// 不向外区分(避免泄露“某用户名是否存在”或后端内部状态)。
pub async fn verify(&self, username: &str, password: &str) -> bool { pub async fn verify(&self, username: &str, password: &str) -> bool {
let actual_hex = sha256_hex(password); let actual_hex = sha256_hex(password);
let expected_hex = match self {
UserStore::Memory(users) => users.get(username).cloned(),
UserStore::Db(pool) => {
let row: Result<Option<(String,)>, sqlx::Error> = let row: Result<Option<(String,)>, sqlx::Error> =
sqlx::query_as("SELECT password_sha256 FROM ccuser WHERE username = ?") sqlx::query_as("SELECT password_sha256 FROM ccuser WHERE username = ?")
.bind(username) .bind(username)
.fetch_optional(pool) .fetch_optional(&self.pool)
.await; .await;
match row { let expected_hex = match row {
Ok(found) => found.map(|(hex,)| hex), Ok(found) => found.map(|(hex,)| hex),
Err(e) => { Err(e) => {
eprintln!("[auth] 查询 ccuser 失败: {e}"); eprintln!("[auth] 查询 ccuser 失败: {e}");
None None
} }
}
}
}; };
match expected_hex { match expected_hex {
Some(expected) => constant_time_eq(actual_hex.as_bytes(), expected.as_bytes()), Some(expected) => constant_time_eq(actual_hex.as_bytes(), expected.as_bytes()),
@ -188,66 +110,30 @@ impl UserStore {
} }
} }
/// 照片墙用户校验后端:查 `wall_users` 表(与 `ccuser` 物理隔离)。 /// 照片墙用户校验后端:一律走 MySQL `wall_users` 表(与 `ccuser` 物理隔离),无写死兜底。
/// 未设 DATABASE_URL 时退回内置兜底。 pub struct WallUserStore {
pub enum WallUserStore { pool: MySqlPool,
Db(MySqlPool),
Memory(HashMap<String, String>),
} }
impl WallUserStore { impl WallUserStore {
pub fn new(pool: Option<MySqlPool>) -> Self { pub fn new(pool: MySqlPool) -> Self {
match pool {
Some(p) => {
eprintln!("[auth] 照片墙用户校验走 MySQL (wall_users)"); eprintln!("[auth] 照片墙用户校验走 MySQL (wall_users)");
WallUserStore::Db(p) WallUserStore { pool }
}
None => {
eprintln!("[auth] 未设置 DATABASE_URL照片墙用户回退到内置表");
Self::seed()
}
}
}
/// 内置兜底cc + UserTest
pub fn seed() -> Self {
let mut users = HashMap::new();
// cc / 192118Lht
users.insert(
"cc".to_string(),
"0086e83c9b108b227eed55425e9641286f42bd0c31a1b95afbb9edd6d3aa6234".to_string(),
);
// UserTest / 2015.08.607
users.insert(
"UserTest".to_string(),
"76f03f2eaa05e66c82d25e168fec27e7d0d202f15da73aa5852562f863633b8e".to_string(),
);
// UserTest6340 / 2019.01.6340
users.insert(
"UserTest6340".to_string(),
"c7780d075f561283ae4665a64d8b8e2642389ad948390a542b847833a7ce7c1a".to_string(),
);
WallUserStore::Memory(users)
} }
pub async fn verify(&self, username: &str, password: &str) -> bool { pub async fn verify(&self, username: &str, password: &str) -> bool {
let actual_hex = sha256_hex(password); let actual_hex = sha256_hex(password);
let expected_hex = match self {
WallUserStore::Memory(users) => users.get(username).cloned(),
WallUserStore::Db(pool) => {
let row: Result<Option<(String,)>, sqlx::Error> = let row: Result<Option<(String,)>, sqlx::Error> =
sqlx::query_as("SELECT password_sha256 FROM wall_users WHERE username = ?") sqlx::query_as("SELECT password_sha256 FROM wall_users WHERE username = ?")
.bind(username) .bind(username)
.fetch_optional(pool) .fetch_optional(&self.pool)
.await; .await;
match row { let expected_hex = match row {
Ok(found) => found.map(|(hex,)| hex), Ok(found) => found.map(|(hex,)| hex),
Err(e) => { Err(e) => {
eprintln!("[auth] 查询 wall_users 失败: {e}"); eprintln!("[auth] 查询 wall_users 失败: {e}");
None None
} }
}
}
}; };
match expected_hex { match expected_hex {
Some(expected) => constant_time_eq(actual_hex.as_bytes(), expected.as_bytes()), Some(expected) => constant_time_eq(actual_hex.as_bytes(), expected.as_bytes()),

View File

@ -17,7 +17,7 @@ use crate::weather::WeatherService;
pub struct AppState { pub struct AppState {
/// 最新一次采样快照。 /// 最新一次采样快照。
pub snapshot: Arc<RwLock<Stats>>, pub snapshot: Arc<RwLock<Stats>>,
/// 用户校验后端MySQL 或内置兜底):内部已是连接池/只读 map`Arc` 共享即可。 /// 用户校验后端MySQL `ccuser`):内部持连接池`Arc` 共享即可。
pub users: Arc<UserStore>, pub users: Arc<UserStore>,
/// 开发者入口门:合法入口 hash 集合,只读共享。 /// 开发者入口门:合法入口 hash 集合,只读共享。
pub gate: Arc<DevGate>, pub gate: Arc<DevGate>,
@ -36,6 +36,7 @@ pub struct AppState {
impl AppState { impl AppState {
pub fn new() -> Self { pub fn new() -> Self {
// 一个连接池给登录校验与入口门共用(克隆是廉价的、共享底层连接)。 // 一个连接池给登录校验与入口门共用(克隆是廉价的、共享底层连接)。
// db_pool_from_env 强制要求 DATABASE_URL缺失则在此 panic 拒绝启动。
let pool = auth::db_pool_from_env(); let pool = auth::db_pool_from_env();
Self { Self {
snapshot: Arc::new(RwLock::new(Stats::default())), snapshot: Arc::new(RwLock::new(Stats::default())),