feat(auth): P0 登录与会话(迁移 v16、argon2id、HttpOnly Cookie)
- 迁移机制: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 记录
This commit is contained in:
Generated
+163
@@ -79,6 +79,18 @@ version = "1.0.104"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
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]]
|
[[package]]
|
||||||
name = "async-stream"
|
name = "async-stream"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@@ -159,12 +171,36 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.2"
|
version = "2.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
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]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
@@ -242,6 +278,36 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -318,6 +384,27 @@ dependencies = [
|
|||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
@@ -656,6 +743,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -694,6 +792,15 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
@@ -712,6 +819,36 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -839,6 +976,17 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -891,6 +1039,7 @@ name = "stash"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"argon2",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"axum",
|
"axum",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -898,9 +1047,11 @@ dependencies = [
|
|||||||
"include_dir",
|
"include_dir",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"notify",
|
"notify",
|
||||||
|
"rand",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower 0.4.13",
|
"tower 0.4.13",
|
||||||
@@ -916,6 +1067,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
@@ -1145,6 +1302,12 @@ dependencies = [
|
|||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicase"
|
name = "unicase"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ notify = "8"
|
|||||||
|
|
||||||
# 内容库:SQLite 随二进制静态编译,运行时不需要任何外部 DLL
|
# 内容库:SQLite 随二进制静态编译,运行时不需要任何外部 DLL
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
argon2 = "0.5"
|
||||||
|
rand = "0.8"
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
# build.rs 用它递归复制前端产物到 $OUT_DIR/web
|
# build.rs 用它递归复制前端产物到 $OUT_DIR/web
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
|||||||
- Rust 工具链(2024 Edition)
|
- Rust 工具链(2024 Edition)
|
||||||
- Node.js 24,推荐用 [Volta](https://volta.sh/) 管理;`stash-web/package.json` 里已用 `volta` 字段固定版本
|
- 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)。
|
||||||
|
|
||||||
## 工作方式
|
## 工作方式
|
||||||
|
|
||||||
请求按下面的顺序命中:
|
请求按下面的顺序命中:
|
||||||
|
|||||||
@@ -144,3 +144,17 @@ DELETE /api/items/:id
|
|||||||
3. 读接口是否也要登录:公开(推荐)/ 全站需登录。
|
3. 读接口是否也要登录:公开(推荐)/ 全站需登录。
|
||||||
4. 内容编辑是否要「草稿 → 发布」两态:先不要(推荐,保持即时生效)/ 要。
|
4. 内容编辑是否要「草稿 → 发布」两态:先不要(推荐,保持即时生效)/ 要。
|
||||||
5. 编辑图片:先只接受 URL(推荐,P2 后期再做上传)/ 首期就要上传。
|
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` 表的读写接口。
|
||||||
|
|
||||||
|
|||||||
+309
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, StashError> {
|
||||||
|
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<bool, StashError> {
|
||||||
|
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<Option<(String, String)>, 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<Option<String>, 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<bool, StashError> {
|
||||||
|
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<String, StashError> {
|
||||||
|
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<SessionState, StashError> {
|
||||||
|
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, "过期会话应被删除");
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-22
@@ -8,6 +8,7 @@
|
|||||||
//! 3. **开发期不做迁移**:表结构还在变,改结构就删掉 `data/stash.db` 重启,
|
//! 3. **开发期不做迁移**:表结构还在变,改结构就删掉 `data/stash.db` 重启,
|
||||||
//! 启动时会重新建表并灌入内嵌种子。
|
//! 启动时会重新建表并灌入内嵌种子。
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
mod catalog;
|
mod catalog;
|
||||||
mod schema;
|
mod schema;
|
||||||
|
|
||||||
@@ -21,6 +22,9 @@ use crate::{config::Config, error::StashError};
|
|||||||
|
|
||||||
pub use schema::SeedReport;
|
pub use schema::SeedReport;
|
||||||
|
|
||||||
|
/// 登录/会话的对外状态(server 层直接用)。
|
||||||
|
pub use auth::SessionState;
|
||||||
|
|
||||||
/// 一次查询得到的完整内容目录。
|
/// 一次查询得到的完整内容目录。
|
||||||
///
|
///
|
||||||
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
|
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
|
||||||
@@ -57,14 +61,14 @@ impl Db {
|
|||||||
)
|
)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
|
|
||||||
// 先校验结构版本,再动任何东西:旧库直接报错,避免用旧表跑新查询
|
// 先迁移结构,再动数据:迁移失败会直接报错,不会用半套表跑查询
|
||||||
let version = schema::check_version(&conn)?;
|
let version = schema::migrate(&conn)?;
|
||||||
|
if version != schema::SCHEMA_VERSION {
|
||||||
schema::apply(&conn)?;
|
return Err(StashError::Database(format!(
|
||||||
|
"内容库迁移后版本为 {version},期望 {}",
|
||||||
// 写入当前结构版本,供下次启动比对
|
schema::SCHEMA_VERSION
|
||||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
|
)));
|
||||||
.map_err(map_error)?;
|
}
|
||||||
|
|
||||||
// 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来
|
// 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来
|
||||||
let existing: i64 = conn
|
let existing: i64 = conn
|
||||||
@@ -115,6 +119,39 @@ impl Db {
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| StashError::Database("数据库连接锁已损坏,请重启进程".into()))
|
.map_err(|_| StashError::Database("数据库连接锁已损坏,请重启进程".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 登录 / 会话 ----------
|
||||||
|
|
||||||
|
pub fn auth_state(&self, token: Option<&str>) -> Result<SessionState, StashError> {
|
||||||
|
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<Option<String>, StashError> {
|
||||||
|
let conn = self.lock()?;
|
||||||
|
auth::purge_expired(&conn)?;
|
||||||
|
auth::verify_login(&conn, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_session(&self) -> Result<String, StashError> {
|
||||||
|
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<bool, StashError> {
|
||||||
|
let mut conn = self.lock()?;
|
||||||
|
auth::change_password(&mut conn, current, next)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
|
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
|
||||||
@@ -446,37 +483,57 @@ mod tests {
|
|||||||
assert!(db.data_version().expect("应能读取 data_version") >= 0);
|
assert!(db.data_version().expect("应能读取 data_version") >= 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 全新库(user_version = 0)允许直接建表;当前版本也允许。
|
/// 全新库(user_version = 0)建全量结构;当前版本直接返回;
|
||||||
|
/// 15 会迁移到 16;比程序新的版本拒绝。
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_fresh_or_current_schema_version() {
|
fn migrates_fresh_previous_and_current_schema() {
|
||||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
schema::check_version(&conn).expect("全新库应放行"),
|
schema::migrate(&conn).expect("全新库应能建表"),
|
||||||
0,
|
schema::SCHEMA_VERSION,
|
||||||
"新库的 user_version 应为 0"
|
"新库应直接建到当前版本"
|
||||||
);
|
);
|
||||||
|
|
||||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
|
let fresh = Connection::open_in_memory().expect("内存库应能打开");
|
||||||
.expect("应能写入版本");
|
fresh
|
||||||
|
.execute_batch(
|
||||||
|
"CREATE TABLE node (key TEXT PRIMARY KEY);
|
||||||
|
PRAGMA user_version = 15;",
|
||||||
|
)
|
||||||
|
.expect("应能造出 v15 老库");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
schema::check_version(&conn).expect("当前版本应放行"),
|
schema::migrate(&fresh).expect("v15 应能迁移"),
|
||||||
schema::SCHEMA_VERSION
|
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]
|
#[test]
|
||||||
fn rejects_database_from_older_schema() {
|
fn rejects_database_from_newer_schema() {
|
||||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION - 1)
|
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION + 1)
|
||||||
.expect("应能写入旧版本号");
|
.expect("应能写入新版本号");
|
||||||
|
|
||||||
let error = schema::check_version(&conn).expect_err("旧库必须被拒绝");
|
let error = schema::migrate(&conn).expect_err("更新的库必须被拒绝");
|
||||||
let message = error.to_string();
|
let message = error.to_string();
|
||||||
|
|
||||||
assert!(message.contains("结构版本"), "报错要说明原因: {message}");
|
assert!(message.contains("结构版本"), "报错要说明原因: {message}");
|
||||||
assert!(message.contains("删除数据库"), "报错要给出处理方式: {message}");
|
assert!(message.contains("升级程序"), "报错要给出处理方式: {message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 回归测试:外部连接提交后,服务端连接读到的 data_version 必须变化。
|
/// 回归测试:外部连接提交后,服务端连接读到的 data_version 必须变化。
|
||||||
|
|||||||
+97
-13
@@ -100,6 +100,60 @@ CREATE TABLE IF NOT EXISTS section_block (
|
|||||||
preview_limit INTEGER, -- 卡片里显示几条;NULL 时用前端默认
|
preview_limit INTEGER, -- 卡片里显示几条;NULL 时用前端默认
|
||||||
sort INTEGER NOT NULL DEFAULT 0 -- 卡片顺序
|
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` 里。
|
/// 当前表结构版本,写在 SQLite 的 `PRAGMA user_version` 里。
|
||||||
///
|
///
|
||||||
/// 开发阶段不做迁移:版本对不上时直接拒绝启动,让用户删库重建,
|
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
||||||
/// 比悄悄用旧表跑新查询(最终表现为 500)好排查得多。
|
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
||||||
/// 结构调整时把这个数字 +1。
|
pub const SCHEMA_VERSION: i64 = 16;
|
||||||
pub const SCHEMA_VERSION: i64 = 15;
|
|
||||||
|
|
||||||
/// 校验库结构版本:0 表示全新库,允许;其它非当前版本一律拒绝。
|
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||||||
pub fn check_version(conn: &Connection) -> Result<i64, StashError> {
|
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||||
let version: i64 = conn
|
.map_err(map_error)
|
||||||
.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<i64, StashError> {
|
||||||
|
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!(
|
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)
|
Ok(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 建表(幂等):每次启动都跑一遍,缺表就补,已有数据不动。
|
/// 建表(幂等):只在测试里直接用;启动流程走 `migrate`。
|
||||||
|
#[cfg(test)]
|
||||||
pub fn apply(conn: &Connection) -> Result<(), StashError> {
|
pub fn apply(conn: &Connection) -> Result<(), StashError> {
|
||||||
conn.execute_batch(SCHEMA_SQL).map_err(map_error)
|
conn.execute_batch(SCHEMA_SQL).map_err(map_error)
|
||||||
}
|
}
|
||||||
|
|||||||
+269
-4
@@ -1,10 +1,10 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{OriginalUri, Path, State},
|
extract::{OriginalUri, Path, State},
|
||||||
http::{HeaderValue, Method, StatusCode, header},
|
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
|
||||||
response::sse::{Event, KeepAlive, Sse},
|
response::sse::{Event, KeepAlive, Sse},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
routing::get,
|
routing::{get, patch, post},
|
||||||
};
|
};
|
||||||
use include_dir::{Dir, include_dir};
|
use include_dir::{Dir, include_dir};
|
||||||
use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
|
use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
|
||||||
@@ -36,6 +36,41 @@ pub struct AppState {
|
|||||||
pub project_updates: broadcast::Sender<Vec<AppProject>>,
|
pub project_updates: broadcast::Sender<Vec<AppProject>>,
|
||||||
/// 内容库:分类 / 分组 / 条目的唯一数据源
|
/// 内容库:分类 / 分组 / 条目的唯一数据源
|
||||||
pub db: Arc<Db>,
|
pub db: Arc<Db>,
|
||||||
|
/// 登录失败计数(单用户本地场景,一个进程一份就够)
|
||||||
|
pub login_guard: Arc<std::sync::Mutex<LoginGuard>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 连续登录失败 5 次 → 锁 60 秒。
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct LoginGuard {
|
||||||
|
failures: u32,
|
||||||
|
locked_until: Option<std::time::Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
pub struct ServerHandle {
|
||||||
@@ -124,9 +159,12 @@ fn not_found_page(path: &str) -> Response {
|
|||||||
/// 到底是路由拼错了还是文件真的不存在。
|
/// 到底是路由拼错了还是文件真的不存在。
|
||||||
const SPA_PREFIXES: &[&str] = &["/c/"];
|
const SPA_PREFIXES: &[&str] = &["/c/"];
|
||||||
|
|
||||||
|
/// 精确匹配的前端页面(没有实体文件,返回入口页)
|
||||||
|
const SPA_PAGES: &[&str] = &["/login", "/admin"];
|
||||||
|
|
||||||
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。
|
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。
|
||||||
fn is_spa_path(path: &str) -> bool {
|
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 页面。
|
/// `ServeDir` 的兜底:前端路由回落到入口页,其余路径仍是 404 页面。
|
||||||
@@ -275,8 +313,228 @@ fn project_event(projects: Vec<AppProject>) -> Event {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- 登录 / 会话 ----------------
|
||||||
|
|
||||||
|
const SESSION_COOKIE: &str = "stash_session";
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct PasswordBody {
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct SetupBody {
|
||||||
|
password: String,
|
||||||
|
username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ChangePasswordBody {
|
||||||
|
current: String,
|
||||||
|
next: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cookie_token(headers: &HeaderMap) -> Option<String> {
|
||||||
|
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<String, Response> {
|
||||||
|
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<crate::db::SessionState, Response> {
|
||||||
|
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<AppState>, 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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<SetupBody>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<PasswordBody>,
|
||||||
|
) -> 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<AppState>, 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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<ChangePasswordBody>,
|
||||||
|
) -> 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 + 内嵌前端 + 托管目录。
|
/// 组装路由:API + 内嵌前端 + 托管目录。
|
||||||
///
|
|
||||||
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
|
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
|
||||||
/// 起监听任务,没法在单元测试里用。
|
/// 起监听任务,没法在单元测试里用。
|
||||||
fn build_router(state: AppState) -> Router {
|
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/projects/events", get(project_events))
|
||||||
.route("/api/catalog", get(catalog_api))
|
.route("/api/catalog", get(catalog_api))
|
||||||
.route("/api/catalog/events", get(catalog_events))
|
.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("/", get(web_index))
|
||||||
.route("/index.html", get(web_index))
|
.route("/index.html", get(web_index))
|
||||||
.route("/favicon.svg", get(web_root_file))
|
.route("/favicon.svg", get(web_root_file))
|
||||||
@@ -333,6 +596,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
|||||||
config: Arc::new(config.clone()),
|
config: Arc::new(config.clone()),
|
||||||
project_updates,
|
project_updates,
|
||||||
db,
|
db,
|
||||||
|
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = build_router(shared_state);
|
let app = build_router(shared_state);
|
||||||
@@ -399,6 +663,7 @@ mod tests {
|
|||||||
config: Arc::new(config),
|
config: Arc::new(config),
|
||||||
project_updates,
|
project_updates,
|
||||||
db: Arc::new(Db::in_memory()),
|
db: Arc::new(Db::in_memory()),
|
||||||
|
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -773,6 +773,38 @@
|
|||||||
white-space: nowrap;
|
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) {
|
@media (max-width: 1100px) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { useProjects } from './useProjects'
|
import { useProjects } from './useProjects'
|
||||||
import { useCatalog } from './useCatalog'
|
import { useCatalog } from './useCatalog'
|
||||||
import { useViewRoute } from './useViewRoute'
|
import { useViewRoute } from './useViewRoute'
|
||||||
|
import { fetchSession, logout as logoutRequest, type SessionState } from './auth'
|
||||||
import { parseViewPath, viewPath, type View } from './data/view'
|
import { parseViewPath, viewPath, type View } from './data/view'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
@@ -38,9 +39,21 @@ export default function App() {
|
|||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
|
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
|
||||||
const { view, go } = useViewRoute()
|
const { view, go } = useViewRoute()
|
||||||
|
const [session, setSession] = useState<SessionState | null>(null)
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
const searchRef = useRef<HTMLInputElement>(null)
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// 登录态:只用来切换页头按钮与后续的编辑入口
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
fetchSession().then((next) => {
|
||||||
|
if (alive) setSession(next)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const categories = useMemo(
|
const categories = useMemo(
|
||||||
() => withLocalCategory(projects, catalog.categories),
|
() => withLocalCategory(projects, catalog.categories),
|
||||||
[projects, catalog.categories],
|
[projects, catalog.categories],
|
||||||
@@ -244,6 +257,10 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
void logoutRequest().then(() => window.location.assign('/'))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
|
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
|
||||||
@@ -270,7 +287,11 @@ export default function App() {
|
|||||||
projectCount={projects.length}
|
projectCount={projects.length}
|
||||||
sampleProject={projects[0]?.name}
|
sampleProject={projects[0]?.name}
|
||||||
live={live}
|
live={live}
|
||||||
|
authenticated={session?.authenticated === true}
|
||||||
|
username={session?.username ?? undefined}
|
||||||
onFocusSearch={() => searchRef.current?.focus()}
|
onFocusSearch={() => searchRef.current?.focus()}
|
||||||
|
onLogin={() => window.location.assign('/login')}
|
||||||
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
sider={
|
sider={
|
||||||
|
|||||||
@@ -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<Result> {
|
||||||
|
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<SessionState> {
|
||||||
|
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<Result> {
|
||||||
|
return request('POST', '/api/auth/login', { password })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setup(password: string, username = 'admin'): Promise<Result> {
|
||||||
|
return request('POST', '/api/auth/setup', { password, username })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(): Promise<Result> {
|
||||||
|
return request('POST', '/api/auth/logout', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changePassword(current: string, next: string): Promise<Result> {
|
||||||
|
return request('PATCH', '/api/auth/password', { current, next })
|
||||||
|
}
|
||||||
@@ -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<SessionState | null>(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 (
|
||||||
|
<div className="login-page">
|
||||||
|
<Card padding="lg" className="login-card">
|
||||||
|
<Space gap="sm" className="login-brand">
|
||||||
|
<span className="site-brand-mark" aria-hidden="true">
|
||||||
|
S
|
||||||
|
</span>
|
||||||
|
<span className="site-brand-name">STASH</span>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Title level={2}>{isSetup ? '设置管理员密码' : '登录'}</Title>
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
{isSetup
|
||||||
|
? '首次使用:设置一个至少 8 位的密码,之后用它在任意设备上登录。'
|
||||||
|
: '登录后可以编辑侧栏、页头滚动信息与内容。'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{error !== '' && (
|
||||||
|
<Notice tone="error" title="操作失败">
|
||||||
|
{error}
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="密码"
|
||||||
|
htmlFor="password"
|
||||||
|
required
|
||||||
|
hint={isSetup ? '至少 8 位,建议混合字母与数字' : undefined}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
size="lg"
|
||||||
|
icon="lock_line"
|
||||||
|
placeholder="输入密码"
|
||||||
|
value={password}
|
||||||
|
autoFocus
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') void submit()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{isSetup && (
|
||||||
|
<FormField label="确认密码" htmlFor="confirm" required>
|
||||||
|
<Input
|
||||||
|
id="confirm"
|
||||||
|
type="password"
|
||||||
|
size="lg"
|
||||||
|
icon="keyhole_line"
|
||||||
|
placeholder="再输入一次"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(event) => setConfirm(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') void submit()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="lg" block loading={busy} icon={<Icon name="lock_line" />} onClick={submit}>
|
||||||
|
{isSetup ? '设置并登录' : '登录'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<a className="login-back" href="/">
|
||||||
|
返回首页
|
||||||
|
</a>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,10 +9,18 @@ type SiteHeaderProps = {
|
|||||||
/** 用于展示的第一个项目名,没有项目时留空 */
|
/** 用于展示的第一个项目名,没有项目时留空 */
|
||||||
sampleProject?: string
|
sampleProject?: string
|
||||||
live: boolean
|
live: boolean
|
||||||
|
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */
|
||||||
|
authenticated: boolean
|
||||||
|
username?: string
|
||||||
onFocusSearch: () => void
|
onFocusSearch: () => void
|
||||||
|
onLogin: () => void
|
||||||
|
onLogout: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type InfoGroupProps = Omit<SiteHeaderProps, 'onFocusSearch'> & {
|
type InfoGroupProps = {
|
||||||
|
projectCount: number
|
||||||
|
sampleProject?: string
|
||||||
|
live: boolean
|
||||||
'aria-hidden'?: 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 (
|
return (
|
||||||
<div className="site-header-inner">
|
<div className="site-header-inner">
|
||||||
<a className="site-brand" href="/" aria-label="Stash 首页">
|
<a className="site-brand" href="/" aria-label="Stash 首页">
|
||||||
@@ -81,8 +98,15 @@ export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }:
|
|||||||
icon={<Icon name="search_3_line" />}
|
icon={<Icon name="search_3_line" />}
|
||||||
onClick={onFocusSearch}
|
onClick={onFocusSearch}
|
||||||
/>
|
/>
|
||||||
{/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */}
|
{authenticated ? (
|
||||||
<IconButton label="登录" icon={<Icon name="user_3_line" />} />
|
<IconButton
|
||||||
|
label={username === undefined ? '退出登录' : `退出登录(${username})`}
|
||||||
|
icon={<Icon name="exit_line" />}
|
||||||
|
onClick={onLogout}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import '@stash/ui/style.css'
|
import '@stash/ui/style.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>{isLoginPage ? <LoginPage /> : <App />}</StrictMode>,
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user