- Client: auth API wrapper (api/auth.ts) + useAuth hook with localStorage persistence - Client: LoginPage/LoginForm with remember-me and loading states - Client: BlogPage with hero section, rainbow spotlight cursor effect, post list - Client: CSS rainbow gradient utilities for text/borders/backgrounds - Server: POST /api/web/login with username/password verification + token - Server: CORS extended to POST + Content-Type header - Server: auth.rs token issuer, serde dependency for JSON - Chore: gitignore Server/.vite Co-Authored-By: Claude <noreply@anthropic.com>
94 lines
3.5 KiB
Rust
94 lines
3.5 KiB
Rust
//! 用户存储与登录校验。
|
||
//!
|
||
//! 设计取舍:用户数很少(≤ 20),所以**不上数据库**——一个进程内的
|
||
//! `HashMap<用户名, 密码SHA256(hex)>` 足矣,启动时构建一次、之后只读。
|
||
//! 数据库要付出的连接池、迁移、Schema 维护成本,在这个规模下并不划算。
|
||
//!
|
||
//! 现在用户表是**写死**在 `UserStore::seed()` 里的(仅 `cc` 一个)。
|
||
//! 将来用户变多时,迁移路径很短,且 handler 完全无需改动:
|
||
//! 1. 改成从 TOML/JSON 文件读:把 `seed()` 换成读文件 → 解析 → 填 map;
|
||
//! 2. 真要运行时增删用户 / 权限 / 审计,再考虑换 SQLite/Postgres。
|
||
//!
|
||
//! 口令永不明文存储:表里存的是 SHA256(hex),登录时把传入明文同样
|
||
//! 哈希后做**等长比较**。(注意:SHA256 仅用于“不落明文”,并非抗暴力破解的
|
||
//! 口令哈希;若将来要对外网开放,应换 Argon2/bcrypt 这类慢哈希。)
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
||
use sha2::{Digest, Sha256};
|
||
|
||
/// 进程内用户表:用户名 → 密码的 SHA256(小写 hex)。
|
||
pub struct UserStore {
|
||
users: HashMap<String, String>,
|
||
}
|
||
|
||
impl UserStore {
|
||
/// 构建写死的用户表。将来可替换为「从文件读取」。
|
||
pub fn seed() -> Self {
|
||
let mut users = HashMap::new();
|
||
|
||
// cc / 192118Lht
|
||
// 下面这串是 SHA256("192118Lht") 的 hex,明文不进源码。
|
||
users.insert(
|
||
"cc".to_string(),
|
||
"0086e83c9b108b227eed55425e9641286f42bd0c31a1b95afbb9edd6d3aa6234".to_string(),
|
||
);
|
||
|
||
Self { users }
|
||
}
|
||
|
||
/// 校验用户名 + 明文密码是否匹配。
|
||
///
|
||
/// 用户名不存在与密码错误都返回 `false`,不向外区分两者
|
||
/// (避免泄露“某用户名是否存在”)。
|
||
pub fn verify(&self, username: &str, password: &str) -> bool {
|
||
match self.users.get(username) {
|
||
Some(expected_hex) => {
|
||
let actual_hex = sha256_hex(password);
|
||
constant_time_eq(actual_hex.as_bytes(), expected_hex.as_bytes())
|
||
}
|
||
None => false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 签发一个登录 token。
|
||
///
|
||
/// 不引入 `rand`/`uuid`:用「纳秒时间戳 + 进程内单调计数器」拼出唯一输入,
|
||
/// 再 SHA256 成不可预测的 hex。够当登录态标识用;将来要做真正的会话校验,
|
||
/// 应换成签名 token(如 JWT)或服务端会话表。
|
||
pub fn issue_token() -> String {
|
||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||
let nanos = 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}"))
|
||
}
|
||
|
||
/// 计算 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
|
||
}
|