From dea7781b86a025c654bc2d7af133c2e22e43abd9 Mon Sep 17 00:00:00 2001 From: stash Date: Thu, 29 Oct 2026 12:41:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20P0=20=E7=99=BB=E5=BD=95=E4=B8=8E?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=EF=BC=88=E8=BF=81=E7=A7=BB=20v16=E3=80=81arg?= =?UTF-8?q?on2id=E3=80=81HttpOnly=20Cookie=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移机制:schema::migrate 支持 15→16,新增 admin/session/setting 表 - 接口:session/setup/login/logout/password;SameSite=Strict + Origin 校验 + 失败限流 - 会话只存 token 的 sha256;改密清空全部会话 - 前端:/login 独立壳(首次=设置密码),页头登录/退出切换 - 测试:37 passed(迁移 + 登录/会话 4 例) - 文档:README 登录说明、docs/ADMIN-PLAN.md 方案与 P0 记录 --- Cargo.lock | 163 +++++++++ Cargo.toml | 3 + README.md | 8 + docs/ADMIN-PLAN.md | 14 + src/db/auth.rs | 309 ++++++++++++++++++ src/db/mod.rs | 101 ++++-- src/db/schema.rs | 110 ++++++- src/server.rs | 273 +++++++++++++++- stash-web/src/App.css | 32 ++ stash-web/src/App.tsx | 21 ++ stash-web/src/auth.ts | 64 ++++ stash-web/src/components/auth/LoginPage.tsx | 126 +++++++ .../src/components/header/SiteHeader.tsx | 32 +- stash-web/src/main.tsx | 12 +- 14 files changed, 1222 insertions(+), 46 deletions(-) create mode 100644 src/db/auth.rs create mode 100644 stash-web/src/auth.ts create mode 100644 stash-web/src/components/auth/LoginPage.tsx diff --git a/Cargo.lock b/Cargo.lock index 00f74a0..fde826b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -159,12 +171,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytes" version = "1.12.1" @@ -242,6 +278,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "errno" version = "0.3.14" @@ -318,6 +384,27 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -656,6 +743,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -694,6 +792,15 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -712,6 +819,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -839,6 +976,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -891,6 +1039,7 @@ name = "stash" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "async-stream", "axum", "clap", @@ -898,9 +1047,11 @@ dependencies = [ "include_dir", "mime_guess", "notify", + "rand", "rusqlite", "serde", "serde_json", + "sha2", "thiserror", "tokio", "tower 0.4.13", @@ -916,6 +1067,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -1145,6 +1302,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicase" version = "2.10.0" diff --git a/Cargo.toml b/Cargo.toml index fec0f7c..04f175a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,9 @@ notify = "8" # 内容库:SQLite 随二进制静态编译,运行时不需要任何外部 DLL rusqlite = { version = "0.32", features = ["bundled"] } +argon2 = "0.5" +rand = "0.8" +sha2 = "0.10" [build-dependencies] # build.rs 用它递归复制前端产物到 $OUT_DIR/web diff --git a/README.md b/README.md index 762081c..4c26132 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 - Rust 工具链(2024 Edition) - Node.js 24,推荐用 [Volta](https://volta.sh/) 管理;`stash-web/package.json` 里已用 `volta` 字段固定版本 +### 登录(v0.1.1 起) + +- 首次打开 `http://127.0.0.1:8080/login` 是**设置密码**(至少 8 位),之后是登录; +- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天); +- 读接口公开(本地工具可只读使用),写接口(`/api/auth/password` 及后续编辑接口)要求登录; +- 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」; +- 方案与后续阶段见 [`docs/ADMIN-PLAN.md`](docs/ADMIN-PLAN.md)。 + ## 工作方式 请求按下面的顺序命中: diff --git a/docs/ADMIN-PLAN.md b/docs/ADMIN-PLAN.md index 174d594..1f634b2 100644 --- a/docs/ADMIN-PLAN.md +++ b/docs/ADMIN-PLAN.md @@ -144,3 +144,17 @@ DELETE /api/items/:id 3. 读接口是否也要登录:公开(推荐)/ 全站需登录。 4. 内容编辑是否要「草稿 → 发布」两态:先不要(推荐,保持即时生效)/ 要。 5. 编辑图片:先只接受 URL(推荐,P2 后期再做上传)/ 首期就要上传。 + +## 11. 实施记录 + +### P0(已完成,2026-10-29) + +- 迁移机制:`user_version 15 → 16`,`schema::migrate` 逐级升级;v15 老库启动时自动补 `admin` / `session` / `setting` 三表; +- 登录:`POST /api/auth/setup`(仅首次)、`/login`、`/logout`、`PATCH /api/auth/password`;argon2id + sha256(token) 落库; +- 会话:HttpOnly + SameSite=Strict + Path=/,7 天;改密后所有会话失效; +- 防护:Origin 同源校验、连续失败 5 次锁 60 秒; +- 前端:`/login` 独立壳(入口按 pathname 分流)、页头登录/退出登录切换; +- 测试:后端 37 个单测(新增迁移 2 个 + 登录 4 个),登录流程 e2e 手动脚本验证通过。 + +**未做(P1/P2)**:`/admin` 设置页、侧栏/页头编辑、内容就编辑、`setting` 表的读写接口。 + diff --git a/src/db/auth.rs b/src/db/auth.rs new file mode 100644 index 0000000..9f4476b --- /dev/null +++ b/src/db/auth.rs @@ -0,0 +1,309 @@ +//! 登录与会话。 +//! +//! - 单管理员:`admin` 表只允许一行,密码用 argon2id 存 PHC 字符串; +//! - 会话:随机 32 字节 token 放在 HttpOnly Cookie 里,库里只存它的 sha256; +//! - 所有函数都是同步的,调用方负责放到 `spawn_blocking` 里。 + +use std::time::{SystemTime, UNIX_EPOCH}; + +use argon2::{ + Argon2, PasswordHash, PasswordHasher, PasswordVerifier, + password_hash::{SaltString, rand_core::OsRng}, +}; +use rusqlite::{Connection, OptionalExtension, params}; +use sha2::{Digest, Sha256}; + +use crate::{db::map_error, error::StashError}; + +/// 会话有效期:7 天 +pub const SESSION_TTL_SECS: i64 = 7 * 24 * 60 * 60; +/// 密码最短长度 +pub const MIN_PASSWORD_LEN: usize = 8; + +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)] +pub struct SessionState { + pub authenticated: bool, + /// 是否已经设置过管理员密码(前端据此显示「登录」还是「首次设置」) + pub has_admin: bool, + pub username: Option, +} + +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0) +} + +fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn random_token() -> String { + let bytes: [u8; 32] = rand::random(); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|error| StashError::Database(format!("密码哈希失败: {error}"))) +} + +fn verify_hash(password: &str, hash: &str) -> bool { + match PasswordHash::new(hash) { + Ok(parsed) => Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok(), + Err(_) => false, + } +} + +pub fn has_admin(conn: &Connection) -> Result { + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM admin", [], |row| row.get(0)) + .map_err(map_error)?; + Ok(count > 0) +} + +fn admin_row(conn: &Connection) -> Result, StashError> { + conn.query_row("SELECT username, password_hash FROM admin WHERE id = 1", [], |row| { + Ok((row.get(0)?, row.get(1)?)) + }) + .optional() + .map_err(map_error) +} + +fn validate_password(password: &str) -> Result<(), StashError> { + if password.chars().count() < MIN_PASSWORD_LEN { + return Err(StashError::InvalidDirectory(format!( + "密码至少 {MIN_PASSWORD_LEN} 位" + ))); + } + Ok(()) +} + +/// 首次设置管理员密码;已经设置过则拒绝。 +pub fn create_admin(conn: &Connection, username: &str, password: &str) -> Result<(), StashError> { + validate_password(password)?; + if has_admin(conn)? { + return Err(StashError::InvalidDirectory("管理员已存在,不能重复初始化".into())); + } + + let hash = hash_password(password)?; + conn.execute( + "INSERT INTO admin (id, username, password_hash, updated_at) VALUES (1, ?1, ?2, ?3)", + params![username, hash, now()], + ) + .map_err(map_error)?; + Ok(()) +} + +/// 校验密码;成功返回用户名。 +pub fn verify_login(conn: &Connection, password: &str) -> Result, StashError> { + let Some((username, hash)) = admin_row(conn)? else { + return Ok(None); + }; + Ok(verify_hash(password, &hash).then_some(username)) +} + +/// 修改密码:必须提供当前密码;成功后清掉其它会话。 +pub fn change_password( + conn: &mut Connection, + current: &str, + next: &str, +) -> Result { + validate_password(next)?; + let Some((_, hash)) = admin_row(conn)? else { + return Err(StashError::InvalidDirectory("尚未设置管理员密码".into())); + }; + if !verify_hash(current, &hash) { + return Ok(false); + } + + let new_hash = hash_password(next)?; + let tx = conn.transaction().map_err(map_error)?; + tx.execute( + "UPDATE admin SET password_hash = ?1, updated_at = ?2 WHERE id = 1", + params![new_hash, now()], + ) + .map_err(map_error)?; + tx.execute("DELETE FROM session", []).map_err(map_error)?; + tx.commit().map_err(map_error)?; + Ok(true) +} + +/// 新建会话,返回**原始 token**(只在这里出现一次,库里存哈希)。 +pub fn issue_session(conn: &Connection) -> Result { + let token = random_token(); + let issued = now(); + conn.execute( + "INSERT INTO session (token_hash, created_at, expires_at, last_seen_at) + VALUES (?1, ?2, ?3, ?2)", + params![hash_token(&token), issued, issued + SESSION_TTL_SECS], + ) + .map_err(map_error)?; + Ok(token) +} + +/// 读取会话状态;token 无效/过期都返回未登录。 +pub fn state(conn: &Connection, token: Option<&str>) -> Result { + let has = has_admin(conn)?; + let Some(token) = token else { + return Ok(SessionState { + authenticated: false, + has_admin: has, + username: None, + }); + }; + + let token_hash = hash_token(token); + let row: Option<(i64,)> = conn + .query_row( + "SELECT expires_at FROM session WHERE token_hash = ?1", + [&token_hash], + |row| Ok((row.get(0)?,)), + ) + .optional() + .map_err(map_error)?; + + let Some((expires_at,)) = row else { + return Ok(SessionState { + authenticated: false, + has_admin: has, + username: None, + }); + }; + + if expires_at <= now() { + conn.execute("DELETE FROM session WHERE token_hash = ?1", [&token_hash]) + .map_err(map_error)?; + return Ok(SessionState { + authenticated: false, + has_admin: has, + username: None, + }); + } + + conn.execute( + "UPDATE session SET last_seen_at = ?1 WHERE token_hash = ?2", + params![now(), token_hash], + ) + .map_err(map_error)?; + + Ok(SessionState { + authenticated: true, + has_admin: has, + username: admin_row(conn)?.map(|(username, _)| username), + }) +} + +pub fn revoke(conn: &Connection, token: &str) -> Result<(), StashError> { + conn.execute("DELETE FROM session WHERE token_hash = ?1", [hash_token(token)]) + .map_err(map_error)?; + Ok(()) +} + +/// 清理过期会话(登录时顺手调用即可)。 +pub fn purge_expired(conn: &Connection) -> Result<(), StashError> { + conn.execute("DELETE FROM session WHERE expires_at <= ?1", [now()]) + .map_err(map_error)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn db() -> Connection { + let conn = Connection::open_in_memory().expect("内存库应能打开"); + conn.execute_batch( + "CREATE TABLE admin ( + id INTEGER PRIMARY KEY CHECK (id = 1), + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE session ( + token_hash TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL + );", + ) + .expect("应能建表"); + conn + } + + #[test] + fn setup_then_login_and_session() { + let conn = db(); + assert!(!has_admin(&conn).expect("可查")); + create_admin(&conn, "admin", "stash-pass-1").expect("首次设置应成功"); + + assert!(has_admin(&conn).expect("可查")); + assert!(verify_login(&conn, "wrong-pass").expect("可校验").is_none()); + assert_eq!( + verify_login(&conn, "stash-pass-1").expect("可校验").as_deref(), + Some("admin") + ); + + let token = issue_session(&conn).expect("应能建会话"); + let session = state(&conn, Some(&token)).expect("应能读状态"); + assert!(session.authenticated); + assert_eq!(session.username.as_deref(), Some("admin")); + + // 原始 token 不落库 + let stored: String = conn + .query_row("SELECT token_hash FROM session", [], |row| row.get(0)) + .expect("应有会话行"); + assert_ne!(stored, token); + assert_eq!(stored, hash_token(&token)); + + revoke(&conn, &token).expect("应能撤销"); + assert!(!state(&conn, Some(&token)).expect("应能读状态").authenticated); + } + + #[test] + fn rejects_short_password_and_second_setup() { + let conn = db(); + assert!(create_admin(&conn, "admin", "short").is_err()); + create_admin(&conn, "admin", "stash-pass-1").expect("首次设置应成功"); + assert!(create_admin(&conn, "admin", "stash-pass-2").is_err()); + } + + #[test] + fn change_password_requires_current_and_clears_sessions() { + let mut conn = db(); + create_admin(&conn, "admin", "stash-pass-1").expect("应能设置"); + let token = issue_session(&conn).expect("应能建会话"); + + assert!(!change_password(&mut conn, "nope", "stash-pass-2").expect("可调用")); + assert!(change_password(&mut conn, "stash-pass-1", "stash-pass-2").expect("可调用")); + + assert_eq!( + verify_login(&conn, "stash-pass-2").expect("可校验").as_deref(), + Some("admin") + ); + assert!(!state(&conn, Some(&token)).expect("可读").authenticated); + } + + #[test] + fn expired_session_is_rejected_and_deleted() { + let conn = db(); + create_admin(&conn, "admin", "stash-pass-1").expect("应能设置"); + let token = issue_session(&conn).expect("应能建会话"); + conn.execute("UPDATE session SET expires_at = ?1", [now() - 1]) + .expect("应能改过期时间"); + + assert!(!state(&conn, Some(&token)).expect("可读").authenticated); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM session", [], |row| row.get(0)) + .expect("可查"); + assert_eq!(count, 0, "过期会话应被删除"); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index b45accb..03f6096 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -8,6 +8,7 @@ //! 3. **开发期不做迁移**:表结构还在变,改结构就删掉 `data/stash.db` 重启, //! 启动时会重新建表并灌入内嵌种子。 +pub mod auth; mod catalog; mod schema; @@ -21,6 +22,9 @@ use crate::{config::Config, error::StashError}; pub use schema::SeedReport; +/// 登录/会话的对外状态(server 层直接用)。 +pub use auth::SessionState; + /// 一次查询得到的完整内容目录。 /// /// 侧栏已改为由这份分类树派生,不再单独下发导航数据。 @@ -57,14 +61,14 @@ impl Db { ) .map_err(map_error)?; - // 先校验结构版本,再动任何东西:旧库直接报错,避免用旧表跑新查询 - let version = schema::check_version(&conn)?; - - schema::apply(&conn)?; - - // 写入当前结构版本,供下次启动比对 - conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION) - .map_err(map_error)?; + // 先迁移结构,再动数据:迁移失败会直接报错,不会用半套表跑查询 + let version = schema::migrate(&conn)?; + if version != schema::SCHEMA_VERSION { + return Err(StashError::Database(format!( + "内容库迁移后版本为 {version},期望 {}", + schema::SCHEMA_VERSION + ))); + } // 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来 let existing: i64 = conn @@ -115,6 +119,39 @@ impl Db { .lock() .map_err(|_| StashError::Database("数据库连接锁已损坏,请重启进程".into())) } + + // ---------- 登录 / 会话 ---------- + + pub fn auth_state(&self, token: Option<&str>) -> Result { + let conn = self.lock()?; + auth::state(&conn, token) + } + + pub fn setup_admin(&self, username: &str, password: &str) -> Result<(), StashError> { + let conn = self.lock()?; + auth::create_admin(&conn, username, password) + } + + pub fn login(&self, password: &str) -> Result, StashError> { + let conn = self.lock()?; + auth::purge_expired(&conn)?; + auth::verify_login(&conn, password) + } + + pub fn issue_session(&self) -> Result { + let conn = self.lock()?; + auth::issue_session(&conn) + } + + pub fn logout(&self, token: &str) -> Result<(), StashError> { + let conn = self.lock()?; + auth::revoke(&conn, token) + } + + pub fn change_password(&self, current: &str, next: &str) -> Result { + let mut conn = self.lock()?; + auth::change_password(&mut conn, current, next) + } } /// 把 rusqlite 的错误统一收敛成 `StashError::Database`。 @@ -446,37 +483,57 @@ mod tests { assert!(db.data_version().expect("应能读取 data_version") >= 0); } - /// 全新库(user_version = 0)允许直接建表;当前版本也允许。 + /// 全新库(user_version = 0)建全量结构;当前版本直接返回; + /// 15 会迁移到 16;比程序新的版本拒绝。 #[test] - fn accepts_fresh_or_current_schema_version() { + fn migrates_fresh_previous_and_current_schema() { let conn = Connection::open_in_memory().expect("内存库应能打开"); assert_eq!( - schema::check_version(&conn).expect("全新库应放行"), - 0, - "新库的 user_version 应为 0" + schema::migrate(&conn).expect("全新库应能建表"), + schema::SCHEMA_VERSION, + "新库应直接建到当前版本" ); - conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION) - .expect("应能写入版本"); + let fresh = Connection::open_in_memory().expect("内存库应能打开"); + fresh + .execute_batch( + "CREATE TABLE node (key TEXT PRIMARY KEY); + PRAGMA user_version = 15;", + ) + .expect("应能造出 v15 老库"); assert_eq!( - schema::check_version(&conn).expect("当前版本应放行"), + schema::migrate(&fresh).expect("v15 应能迁移"), schema::SCHEMA_VERSION ); + + let migrated: i64 = fresh + .pragma_query_value(None, "user_version", |row| row.get(0)) + .expect("应能读出版本"); + assert_eq!(migrated, schema::SCHEMA_VERSION); + + let table: i64 = fresh + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('admin','session','setting')", + [], + |row| row.get(0), + ) + .expect("应能查表"); + assert_eq!(table, 3, "迁移后必须有登录/设置三张表"); } - /// 结构变过之后,旧库必须被拒绝并提示删库,而不是用旧表跑新查询。 + /// 比程序更新的库必须被拒绝,不能按旧结构乱读。 #[test] - fn rejects_database_from_older_schema() { + fn rejects_database_from_newer_schema() { let conn = Connection::open_in_memory().expect("内存库应能打开"); - conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION - 1) - .expect("应能写入旧版本号"); + conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION + 1) + .expect("应能写入新版本号"); - let error = schema::check_version(&conn).expect_err("旧库必须被拒绝"); + let error = schema::migrate(&conn).expect_err("更新的库必须被拒绝"); let message = error.to_string(); assert!(message.contains("结构版本"), "报错要说明原因: {message}"); - assert!(message.contains("删除数据库"), "报错要给出处理方式: {message}"); + assert!(message.contains("升级程序"), "报错要给出处理方式: {message}"); } /// 回归测试:外部连接提交后,服务端连接读到的 data_version 必须变化。 diff --git a/src/db/schema.rs b/src/db/schema.rs index c6b0d3d..ef4fc1a 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -100,6 +100,60 @@ CREATE TABLE IF NOT EXISTS section_block ( preview_limit INTEGER, -- 卡片里显示几条;NULL 时用前端默认 sort INTEGER NOT NULL DEFAULT 0 -- 卡片顺序 ); + +-- ========== v16:登录与设置 ========== + +-- 管理员:单用户场景只允许一行 +CREATE TABLE IF NOT EXISTS admin ( + id INTEGER PRIMARY KEY CHECK (id = 1), + username TEXT NOT NULL, + password_hash TEXT NOT NULL, -- argon2id PHC 字符串 + updated_at INTEGER NOT NULL -- unix 秒 +); + +-- 会话:只存 token 的 sha256,原始 token 只在 Cookie 里 +CREATE TABLE IF NOT EXISTS session ( + token_hash TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS session_expiry_idx ON session(expires_at); + +-- 全局设置:一个 key 一段 JSON,version 用于乐观锁 +CREATE TABLE IF NOT EXISTS setting ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL +); +"#; + +/// v15 → v16 的增量(老库升级用;新库直接走 SCHEMA_SQL)。 +const V16_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS admin ( + id INTEGER PRIMARY KEY CHECK (id = 1), + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS session ( + token_hash TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS session_expiry_idx ON session(expires_at); + +CREATE TABLE IF NOT EXISTS setting ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL +); "#; /// 种子导入结果,仅用于启动日志。 @@ -115,28 +169,58 @@ pub struct SeedReport { /// 当前表结构版本,写在 SQLite 的 `PRAGMA user_version` 里。 /// -/// 开发阶段不做迁移:版本对不上时直接拒绝启动,让用户删库重建, -/// 比悄悄用旧表跑新查询(最终表现为 500)好排查得多。 -/// 结构调整时把这个数字 +1。 -pub const SCHEMA_VERSION: i64 = 15; +/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来, +/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。 +pub const SCHEMA_VERSION: i64 = 16; -/// 校验库结构版本:0 表示全新库,允许;其它非当前版本一律拒绝。 -pub fn check_version(conn: &Connection) -> Result { - let version: i64 = conn - .pragma_query_value(None, "user_version", |row| row.get(0)) - .map_err(map_error)?; +fn current_version(conn: &Connection) -> Result { + conn.pragma_query_value(None, "user_version", |row| row.get(0)) + .map_err(map_error) +} - if version != 0 && version != SCHEMA_VERSION { +/// 把库升到 `SCHEMA_VERSION`,返回升级后的版本。 +/// +/// - 0:全新库,建全量结构; +/// - 15:补 v16 的登录/设置表; +/// - 16:已是最新; +/// - 其它:拒绝启动(不猜结构)。 +pub fn migrate(conn: &Connection) -> Result { + let mut version = current_version(conn)?; + + if version == 0 { + conn.execute_batch(SCHEMA_SQL).map_err(map_error)?; + conn.pragma_update(None, "user_version", SCHEMA_VERSION) + .map_err(map_error)?; + return Ok(SCHEMA_VERSION); + } + + if version > SCHEMA_VERSION { return Err(StashError::Database(format!( - "内容库结构版本为 {version},当前程序需要 {SCHEMA_VERSION}。\ - 开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。" + "内容库结构版本为 {version},比当前程序({SCHEMA_VERSION})更新;\ + 请升级程序,或用旧版本打开。" ))); } + if version < 15 { + return Err(StashError::Database(format!( + "内容库结构版本为 {version},缺少可用的迁移路径(当前程序支持 15 → {SCHEMA_VERSION})。\ + 请备份后删除数据库重建。" + ))); + } + + if version == 15 { + conn.execute_batch(V16_SQL).map_err(map_error)?; + version = 16; + } + + conn.pragma_update(None, "user_version", version) + .map_err(map_error)?; + Ok(version) } -/// 建表(幂等):每次启动都跑一遍,缺表就补,已有数据不动。 +/// 建表(幂等):只在测试里直接用;启动流程走 `migrate`。 +#[cfg(test)] pub fn apply(conn: &Connection) -> Result<(), StashError> { conn.execute_batch(SCHEMA_SQL).map_err(map_error) } diff --git a/src/server.rs b/src/server.rs index f566493..9501a66 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,10 +1,10 @@ use axum::{ Json, Router, extract::{OriginalUri, Path, State}, - http::{HeaderValue, Method, StatusCode, header}, + http::{HeaderMap, HeaderValue, Method, StatusCode, header}, response::sse::{Event, KeepAlive, Sse}, response::{Html, IntoResponse, Response}, - routing::get, + routing::{get, patch, post}, }; use include_dir::{Dir, include_dir}; use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration}; @@ -36,6 +36,41 @@ pub struct AppState { pub project_updates: broadcast::Sender>, /// 内容库:分类 / 分组 / 条目的唯一数据源 pub db: Arc, + /// 登录失败计数(单用户本地场景,一个进程一份就够) + pub login_guard: Arc>, +} + +/// 连续登录失败 5 次 → 锁 60 秒。 +#[derive(Debug, Default)] +pub struct LoginGuard { + failures: u32, + locked_until: Option, +} + +impl LoginGuard { + fn locked(&mut self) -> bool { + if let Some(until) = self.locked_until { + if std::time::Instant::now() < until { + return true; + } + self.failures = 0; + self.locked_until = None; + } + false + } + + fn record_failure(&mut self) { + self.failures += 1; + if self.failures >= 5 { + self.locked_until = Some(std::time::Instant::now() + Duration::from_secs(60)); + self.failures = 0; + } + } + + fn reset(&mut self) { + self.failures = 0; + self.locked_until = None; + } } pub struct ServerHandle { @@ -124,9 +159,12 @@ fn not_found_page(path: &str) -> Response { /// 到底是路由拼错了还是文件真的不存在。 const SPA_PREFIXES: &[&str] = &["/c/"]; +/// 精确匹配的前端页面(没有实体文件,返回入口页) +const SPA_PAGES: &[&str] = &["/login", "/admin"]; + /// 是否为前端路由路径(没有实体文件,应该返回入口页)。 fn is_spa_path(path: &str) -> bool { - SPA_PREFIXES.iter().any(|prefix| path.starts_with(prefix)) + SPA_PAGES.contains(&path) || SPA_PREFIXES.iter().any(|prefix| path.starts_with(prefix)) } /// `ServeDir` 的兜底:前端路由回落到入口页,其余路径仍是 404 页面。 @@ -275,8 +313,228 @@ fn project_event(projects: Vec) -> Event { }) } +// ---------------- 登录 / 会话 ---------------- + +const SESSION_COOKIE: &str = "stash_session"; + +#[derive(serde::Deserialize)] +struct PasswordBody { + password: String, +} + +#[derive(serde::Deserialize)] +struct SetupBody { + password: String, + username: Option, +} + +#[derive(serde::Deserialize)] +struct ChangePasswordBody { + current: String, + next: String, +} + +fn cookie_token(headers: &HeaderMap) -> Option { + let raw = headers.get(header::COOKIE)?.to_str().ok()?; + raw.split(';') + .filter_map(|part| part.trim().split_once('=')) + .find(|(key, _)| *key == SESSION_COOKIE) + .map(|(_, value)| value.to_string()) +} + +fn session_cookie(token: &str) -> String { + format!( + "{SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}", + crate::db::auth::SESSION_TTL_SECS + ) +} + +fn clear_cookie() -> String { + format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0") +} + +fn api_error(status: StatusCode, message: &str) -> Response { + (status, Json(serde_json::json!({ "error": message }))).into_response() +} + +fn with_cookie(mut response: Response, cookie: String) -> Response { + if let Ok(value) = HeaderValue::from_str(&cookie) { + response.headers_mut().append(header::SET_COOKIE, value); + } + response +} + +/// 同源校验:跨站请求带 Origin 且 host 不匹配时拒绝。 +/// 和 Cookie 的 `SameSite=Strict` 互为补充,不依赖浏览器实现。 +fn same_origin(headers: &HeaderMap) -> bool { + let Some(origin) = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + else { + return true; + }; + let Some(host) = headers.get(header::HOST).and_then(|value| value.to_str().ok()) else { + return false; + }; + origin.ends_with(host) +} + +fn login_locked(state: &AppState) -> bool { + state + .login_guard + .lock() + .map(|mut guard| guard.locked()) + .unwrap_or(true) +} + +fn login_failed(state: &AppState) { + if let Ok(mut guard) = state.login_guard.lock() { + guard.record_failure(); + } +} + +fn login_ok(state: &AppState) { + if let Ok(mut guard) = state.login_guard.lock() { + guard.reset(); + } +} + +async fn issue_token(state: &AppState) -> Result { + let db = Arc::clone(&state.db); + tokio::task::spawn_blocking(move || db.issue_session()) + .await + .map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()))? + .map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string())) +} + +async fn read_session(state: &AppState, headers: &HeaderMap) -> Result { + let token = cookie_token(headers); + let db = Arc::clone(&state.db); + tokio::task::spawn_blocking(move || db.auth_state(token.as_deref())) + .await + .map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()))? + .map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string())) +} + +/// GET /api/auth/session +async fn auth_session(State(state): State, headers: HeaderMap) -> Response { + match read_session(&state, &headers).await { + Ok(session) => Json(session).into_response(), + Err(response) => response, + } +} + +/// POST /api/auth/setup —— 只在还没有管理员时可用 +async fn auth_setup( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !same_origin(&headers) { + return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); + } + + let username = body.username.unwrap_or_else(|| "admin".into()); + let password = body.password; + let db = Arc::clone(&state.db); + let created = tokio::task::spawn_blocking(move || db.setup_admin(&username, &password)).await; + + match created { + Ok(Ok(())) => match issue_token(&state).await { + Ok(token) => with_cookie( + Json(serde_json::json!({ "ok": true })).into_response(), + session_cookie(&token), + ), + Err(response) => response, + }, + Ok(Err(error)) => api_error(StatusCode::BAD_REQUEST, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + +/// POST /api/auth/login +async fn auth_login( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !same_origin(&headers) { + return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); + } + if login_locked(&state) { + return api_error(StatusCode::TOO_MANY_REQUESTS, "尝试过多,请一分钟后再试"); + } + + let password = body.password; + let db = Arc::clone(&state.db); + let verified = tokio::task::spawn_blocking(move || db.login(&password)).await; + + match verified { + Ok(Ok(Some(_username))) => { + login_ok(&state); + match issue_token(&state).await { + Ok(token) => with_cookie( + Json(serde_json::json!({ "ok": true })).into_response(), + session_cookie(&token), + ), + Err(response) => response, + } + } + Ok(Ok(None)) => { + login_failed(&state); + api_error(StatusCode::UNAUTHORIZED, "密码错误") + } + Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + +/// POST /api/auth/logout +async fn auth_logout(State(state): State, headers: HeaderMap) -> Response { + if let Some(token) = cookie_token(&headers) { + let db = Arc::clone(&state.db); + let _ = tokio::task::spawn_blocking(move || db.logout(&token)).await; + } + + with_cookie( + Json(serde_json::json!({ "ok": true })).into_response(), + clear_cookie(), + ) +} + +/// PATCH /api/auth/password —— 必须已登录,成功后所有会话失效 +async fn auth_password( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !same_origin(&headers) { + return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); + } + + match read_session(&state, &headers).await { + Ok(session) if session.authenticated => {} + Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), + Err(response) => return response, + } + + let db = Arc::clone(&state.db); + let (current, next) = (body.current, body.next); + let changed = + tokio::task::spawn_blocking(move || db.change_password(¤t, &next)).await; + + match changed { + Ok(Ok(true)) => with_cookie( + Json(serde_json::json!({ "ok": true })).into_response(), + clear_cookie(), + ), + Ok(Ok(false)) => api_error(StatusCode::UNAUTHORIZED, "当前密码不正确"), + Ok(Err(error)) => api_error(StatusCode::BAD_REQUEST, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + /// 组装路由:API + 内嵌前端 + 托管目录。 -/// /// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、 /// 起监听任务,没法在单元测试里用。 fn build_router(state: AppState) -> Router { @@ -296,6 +554,11 @@ fn build_router(state: AppState) -> Router { .route("/api/projects/events", get(project_events)) .route("/api/catalog", get(catalog_api)) .route("/api/catalog/events", get(catalog_events)) + .route("/api/auth/session", get(auth_session)) + .route("/api/auth/setup", post(auth_setup)) + .route("/api/auth/login", post(auth_login)) + .route("/api/auth/logout", post(auth_logout)) + .route("/api/auth/password", patch(auth_password)) .route("/", get(web_index)) .route("/index.html", get(web_index)) .route("/favicon.svg", get(web_root_file)) @@ -333,6 +596,7 @@ pub async fn start(config: Config) -> Result { config: Arc::new(config.clone()), project_updates, db, + login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())), }; let app = build_router(shared_state); @@ -399,6 +663,7 @@ mod tests { config: Arc::new(config), project_updates, db: Arc::new(Db::in_memory()), + login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())), }) } diff --git a/stash-web/src/App.css b/stash-web/src/App.css index 865277e..184e53c 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -773,6 +773,38 @@ white-space: nowrap; } +/* ---------- 登录页 ---------- */ + +.login-page { + display: grid; + place-items: center; + min-height: 100vh; + min-height: 100dvh; + padding: var(--su-space-6); + background-color: var(--su-bg-page); +} + +.login-card { + display: flex; + flex-direction: column; + gap: var(--su-space-4); + width: min(380px, 100%); +} + +.login-brand { + align-items: center; +} + +.login-back { + font-size: var(--su-font-size-sm); + color: var(--su-text-muted); + text-align: center; +} + +.login-back:hover { + color: var(--su-color-primary); +} + /* ---------- 响应式 ---------- */ @media (max-width: 1100px) { diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx index 60a19c7..61acdba 100644 --- a/stash-web/src/App.tsx +++ b/stash-web/src/App.tsx @@ -28,6 +28,7 @@ import { import { useProjects } from './useProjects' import { useCatalog } from './useCatalog' import { useViewRoute } from './useViewRoute' +import { fetchSession, logout as logoutRequest, type SessionState } from './auth' import { parseViewPath, viewPath, type View } from './data/view' import './App.css' @@ -38,9 +39,21 @@ export default function App() { const [keyword, setKeyword] = useState('') // 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts) const { view, go } = useViewRoute() + const [session, setSession] = useState(null) const [scrolled, setScrolled] = useState(false) const searchRef = useRef(null) + // 登录态:只用来切换页头按钮与后续的编辑入口 + useEffect(() => { + let alive = true + fetchSession().then((next) => { + if (alive) setSession(next) + }) + return () => { + alive = false + } + }, []) + const categories = useMemo( () => withLocalCategory(projects, catalog.categories), [projects, catalog.categories], @@ -244,6 +257,10 @@ export default function App() { ) } + const handleLogout = () => { + void logoutRequest().then(() => window.location.assign('/')) + } + return ( <> {/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */} @@ -270,7 +287,11 @@ export default function App() { projectCount={projects.length} sampleProject={projects[0]?.name} live={live} + authenticated={session?.authenticated === true} + username={session?.username ?? undefined} onFocusSearch={() => searchRef.current?.focus()} + onLogin={() => window.location.assign('/login')} + onLogout={handleLogout} /> } sider={ diff --git a/stash-web/src/auth.ts b/stash-web/src/auth.ts new file mode 100644 index 0000000..e097cf9 --- /dev/null +++ b/stash-web/src/auth.ts @@ -0,0 +1,64 @@ +/** + * 登录/会话的接口封装。 + * + * 会话是 HttpOnly Cookie,前端拿不到 token,只能问 `/api/auth/session`。 + */ +export type SessionState = { + authenticated: boolean + has_admin: boolean + username?: string | null +} + +type Result = { ok: true } | { ok: false; status: number; error: string } + +async function request( + method: 'POST' | 'PATCH', + url: string, + body: unknown, +): Promise { + try { + const response = await fetch(url, { + method, + credentials: 'same-origin', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + + if (response.ok) return { ok: true } + + const payload = (await response.json().catch(() => null)) as { error?: string } | null + return { + ok: false, + status: response.status, + error: payload?.error ?? `请求失败(${response.status})`, + } + } catch (error) { + return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误' } + } +} + +export async function fetchSession(): Promise { + try { + const response = await fetch('/api/auth/session', { credentials: 'same-origin' }) + if (!response.ok) return { authenticated: false, has_admin: false } + return (await response.json()) as SessionState + } catch { + return { authenticated: false, has_admin: false } + } +} + +export function login(password: string): Promise { + return request('POST', '/api/auth/login', { password }) +} + +export function setup(password: string, username = 'admin'): Promise { + return request('POST', '/api/auth/setup', { password, username }) +} + +export function logout(): Promise { + return request('POST', '/api/auth/logout', {}) +} + +export function changePassword(current: string, next: string): Promise { + return request('PATCH', '/api/auth/password', { current, next }) +} diff --git a/stash-web/src/components/auth/LoginPage.tsx b/stash-web/src/components/auth/LoginPage.tsx new file mode 100644 index 0000000..e117cb4 --- /dev/null +++ b/stash-web/src/components/auth/LoginPage.tsx @@ -0,0 +1,126 @@ +import { Button, Card, FormField, Icon, Input, Notice, Space, Text, Title } from '@stash/ui' +import { useEffect, useState } from 'react' +import { fetchSession, login, setup, type SessionState } from '../../auth' + +/** + * 登录页:首次打开是「设置密码」,之后是「登录」。 + * + * 成功后整页跳回首页(而不是 SPA 内跳转),让服务端重新下发 Cookie 后的首帧 + * 也带上登录态,避免首屏闪一下未登录 UI。 + */ +export function LoginPage() { + const [session, setSession] = useState(null) + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + + useEffect(() => { + let alive = true + fetchSession().then((next) => { + if (!alive) return + if (next.authenticated) { + window.location.assign('/') + return + } + setSession(next) + }) + return () => { + alive = false + } + }, []) + + const isSetup = session !== null && !session.has_admin + + const submit = async () => { + setError('') + + if (isSetup && password !== confirm) { + setError('两次输入的密码不一致') + return + } + + setBusy(true) + const result = isSetup ? await setup(password) : await login(password) + setBusy(false) + + if (result.ok) { + window.location.assign('/') + return + } + + setError(result.error) + } + + return ( +
+ + + + STASH + + + {isSetup ? '设置管理员密码' : '登录'} + + {isSetup + ? '首次使用:设置一个至少 8 位的密码,之后用它在任意设备上登录。' + : '登录后可以编辑侧栏、页头滚动信息与内容。'} + + + {error !== '' && ( + + {error} + + )} + + + setPassword(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void submit() + }} + /> + + + {isSetup && ( + + setConfirm(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void submit() + }} + /> + + )} + + + + + 返回首页 + + +
+ ) +} diff --git a/stash-web/src/components/header/SiteHeader.tsx b/stash-web/src/components/header/SiteHeader.tsx index d06212d..1a89e61 100644 --- a/stash-web/src/components/header/SiteHeader.tsx +++ b/stash-web/src/components/header/SiteHeader.tsx @@ -9,10 +9,18 @@ type SiteHeaderProps = { /** 用于展示的第一个项目名,没有项目时留空 */ sampleProject?: string live: boolean + /** 是否已登录:决定右侧是「登录」还是「退出登录」 */ + authenticated: boolean + username?: string onFocusSearch: () => void + onLogin: () => void + onLogout: () => void } -type InfoGroupProps = Omit & { +type InfoGroupProps = { + projectCount: number + sampleProject?: string + live: boolean 'aria-hidden'?: boolean } @@ -46,7 +54,16 @@ function InfoGroup({ ) } -export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }: SiteHeaderProps) { +export function SiteHeader({ + projectCount, + sampleProject, + live, + authenticated, + username, + onFocusSearch, + onLogin, + onLogout, +}: SiteHeaderProps) { return ( ) diff --git a/stash-web/src/main.tsx b/stash-web/src/main.tsx index dc07bed..3b94d31 100644 --- a/stash-web/src/main.tsx +++ b/stash-web/src/main.tsx @@ -3,9 +3,15 @@ import { createRoot } from 'react-dom/client' import '@stash/ui/style.css' import './index.css' import App from './App.tsx' +import { LoginPage } from './components/auth/LoginPage' + +/** + * 入口分流:登录页是独立壳(不挂 Layout / 侧栏 / 搜索), + * 且必须在 App 的 `useViewRoute` 把地址规范化之前判定, + * 所以放在这里用首次加载的 pathname 决定,而不是放进 App 里。 + */ +const isLoginPage = window.location.pathname === '/login' createRoot(document.getElementById('root')!).render( - - - , + {isLoginPage ? : }, )