diff --git a/README.md b/README.md index 44a77ce..fb87de5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容) - **管理**:单管理员口令登录(Argon2id)、会话 7 天滑动续期(CSRF 轮换保持不变)、防爆破限流(per-IP + per-账号)、改密 - **回收站**:删除 = 软删除,30 天内可恢复,`gc` 到期物理清除 -- **运维**:`init` / `serve` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version` +- **运维**:`init` / `reset-password`(重设口令并吊销全部会话)/ `serve` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version` ## 快速开始 @@ -39,6 +39,13 @@ make build cd web && npm install && npm run dev # /api 代理到 127.0.0.1:8080 ``` +忘记口令(CLI 可达即具备服务器权限,可直接重设;重设后全部会话被吊销): + +```bash +./pure-note reset-password --data-dir ./data +# 非交互:PN_ADMIN_PASSWORD=xxx ./pure-note reset-password --data-dir ./data +``` + ## 测试 ```bash @@ -71,7 +78,7 @@ sudo systemctl enable --now pure-note pure-note-maint.timer ## 架构 ``` -cmd/pure-note/ CLI(serve/init/backup/gc/version) +cmd/pure-note/ CLI(serve/init/reset-password/backup/gc/version) internal/config/ 命令行解析(--dev loopback 守卫) internal/store/ SQLite(modernc 纯 Go 驱动):迁移(user_version) + DAO + 备份 + gc internal/auth/ Argon2id(PHC) + 随机 token diff --git a/cmd/pure-note/main.go b/cmd/pure-note/main.go index 5e6bbeb..243e000 100644 --- a/cmd/pure-note/main.go +++ b/cmd/pure-note/main.go @@ -1,4 +1,4 @@ -// pure-note 单二进制入口:serve / init / backup / gc / version(§7.6)。 +// pure-note 单二进制入口:serve / init / reset-password / backup / gc / version(§7.6)。 package main import ( @@ -42,6 +42,8 @@ func main() { err = runServe(os.Args[2:]) case "init": err = runInit(os.Args[2:]) + case "reset-password": + err = runResetPassword(os.Args[2:]) case "backup": err = runBackup(os.Args[2:]) case "gc": @@ -67,6 +69,7 @@ func usage() { 用法: pure-note serve [--addr :8080] [--data-dir ./data] [--behind-proxy] [--dev] [--allow-newer] pure-note init [--data-dir ./data] + pure-note reset-password [--data-dir ./data] # 重设管理员口令并吊销全部会话 pure-note backup [--data-dir ./data] [输出路径] pure-note gc [--data-dir ./data] [--commit] # 默认 dry-run pure-note version @@ -225,6 +228,48 @@ func runInit(args []string) error { return nil } +// runResetPassword 重设管理员口令:CLI 可达即具备服务器权限,允许直接覆盖。 +// 口令来源与 init 一致:环境变量 PN_ADMIN_PASSWORD 或交互输入(避免出现在 ps 参数里)。 +// 重设成功后吊销全部会话,强制所有端重新登录。 +func runResetPassword(args []string) error { + cfg, _, err := config.ParseMaint("reset-password", args) + if err != nil { + return err + } + st, err := store.Open(cfg.DBPath(), false) + if err != nil { + return err + } + defer st.Close() + + var password string + if env := os.Getenv("PN_ADMIN_PASSWORD"); env != "" { + password = env + fmt.Fprintln(os.Stderr, "已从环境变量 PN_ADMIN_PASSWORD 读取口令") + } else { + password, err = promptPassword() + if err != nil { + return err + } + } + if err := auth.CheckPasswordStrength(password); err != nil { + return err + } + hash, err := auth.HashPassword(password) + if err != nil { + return err + } + if err := st.SetSetting(store.KeyAdminPasswordHash, hash); err != nil { + return err + } + revoked, err := st.DeleteAllSessions() + if err != nil { + return err + } + fmt.Printf("口令已重设;已吊销 %d 个会话,所有端需重新登录。\n", revoked) + return nil +} + func promptPassword() (string, error) { fmt.Print("设置管理员口令(≥12 字符): ") b1, err := term.ReadPassword(int(os.Stdin.Fd())) diff --git a/docs/acceptance.md b/docs/acceptance.md index f517f98..41f7c20 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -71,7 +71,7 @@ ``` pure-note(单二进制,CGO_ENABLED=0,go:embed 前端产物) -├── cmd/pure-note/ serve / init / backup / gc / version +├── cmd/pure-note/ serve / init / reset-password / backup / gc / version ├── internal/{config,store,auth,markdown,middleware,httpapi,webui} ├── web/ React 19 + Vite 8 + Tailwind 4(vitest 6 用例) ├── deploy/ pure-note.service、pure-note-maint.{service,timer}、Caddyfile diff --git a/docs/design.md b/docs/design.md index 1ae063e..22ce54a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -177,7 +177,7 @@ pure-note/ │ └── review-round1.md # 第 1 轮审评报告(修订依据,留档) ├── cmd/ │ └── pure-note/ -│ └── main.go # 入口 + 命令行(serve/init/backup/gc/version) +│ └── main.go # 入口 + 命令行(serve/init/reset-password/backup/gc/version) ├── internal/ │ ├── config/ # 环境变量/flag 解析 │ ├── store/ # SQLite 打开、迁移(user_version 上界校验)、DAO @@ -355,7 +355,8 @@ SecurityHeaders(含 HSTS,§9.2) 5. **轮换**:活跃访问距过期 < 3 天时重建会话行、旋转 Cookie token,**csrf_token 保持不变**(前端无感); 6. **登出**:删除会话行; 7. **改密**:`POST /api/admin/password` 校验旧密码后写新 PHC 哈希;改密不失效当前会话(可选:改密后删除其他会话——单管理员场景仅本会话,不强制); -8. **开发模式**:`--dev` 允许非 Secure Cookie(否则 `__Host-` 在 `http://局域网IP` 下无法登录);**强制约束:仅当监听地址为 loopback 时允许启动,防止误部署到生产**。 +8. **口令重置(CLI)**:`pure-note reset-password` 免旧口令直接覆盖哈希并**吊销全部会话**——前提是具备服务器访问权限(读写数据目录即可改库,CLI 提供规范入口优于手工改库);口令来源与 `init` 一致,避免出现在 argv; +9. **开发模式**:`--dev` 允许非 Secure Cookie(否则 `__Host-` 在 `http://局域网IP` 下无法登录);**强制约束:仅当监听地址为 loopback 时允许启动,防止误部署到生产**。 ### 7.4 图片上传与访问 @@ -377,6 +378,7 @@ SecurityHeaders(含 HSTS,§9.2) | --- | --- | | `pure-note serve` | 启动;`--addr :8080`、`--data-dir ./data`、`--log-level`、`--log-format text|json`、`--behind-proxy`(声明位于可信反代之后,启用 XFF 处理)、`--dev`(loopback-only,§7.3-8)、`--allow-newer`(跳过 schema 版本上界校验,§10.4);环境变量:`PN_ADMIN_PASSWORD` | | `pure-note init` | 首次初始化:设口令(Argon2id+PHC)、站点标题 | +| `pure-note reset-password` | 重设管理员口令(覆盖旧哈希)并吊销全部会话;口令来源同 `init`(环境变量 `PN_ADMIN_PASSWORD` 或交互输入,不经 argv 避免 ps 泄露)。CLI 可达即具备服务器权限,允许直接重设 | | `pure-note backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 | | `pure-note gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) | | `pure-note version` | 版本号(build 时注入 commit/时间) | diff --git a/internal/store/sessions.go b/internal/store/sessions.go index c05e542..aa9ecd6 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -76,3 +76,12 @@ func (s *Store) DeleteExpiredSessions(now int64) (int64, error) { } return res.RowsAffected() } + +// DeleteAllSessions 吊销全部会话(口令重置后强制所有端重新登录)。 +func (s *Store) DeleteAllSessions() (int64, error) { + res, err := s.db.Exec(`DELETE FROM sessions`) + if err != nil { + return 0, err + } + return res.RowsAffected() +}