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:
+97
-13
@@ -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<i64, StashError> {
|
||||
let version: i64 = conn
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.map_err(map_error)?;
|
||||
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||||
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<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!(
|
||||
"内容库结构版本为 {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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user