This commit is contained in:
Autumn-27 committed 2026-10-07 07:45:28 -04:00
1 parent b55ceb1fdd
commit a951e4aad3
6 files changed
+279 -44

No files matched your search

+63 -5
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
@@ -20,8 +21,30 @@ const (
authPassKey = "auth.password_hash"
jwtTTL = 7 * 24 * time.Hour
keyChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// 下限与 setup 页的前端校验一致——校验只放在前端等于没放,直接打 API 就能
// 绕过。上限是 bcrypt 的硬限制:超过 72 字节 GenerateFromPassword 会返回
// ErrPasswordTooLong,提前挡掉好过让用户收到一句含义不明的「密码加密失败」。
minPasswordRunes = 8
maxPasswordBytes = 72
)
// errDataSourceUnavailable 是密码相关读操作失败时统一的回复。这些 handler 绝不能
// 把"读不到"当成"没有设置":authInit 曾因此在数据库报错时放行,让未认证请求覆盖
// 掉已有的管理员密码。
const errDataSourceUnavailable = "数据源暂时不可用,请稍后重试"
// validatePassword 返回空串表示通过,否则返回可直接展示给用户的中文原因。
func validatePassword(pw string) string {
if utf8.RuneCountInString(pw) < minPasswordRunes {
return fmt.Sprintf("密码长度至少 %d 位", minPasswordRunes)
}
if len(pw) > maxPasswordBytes {
return fmt.Sprintf("密码长度不能超过 %d 字节", maxPasswordBytes)
}
return ""
}
// loadOrCreateJWTKey reads the 32-byte signing key from keyDir/jwt.key. keyDir is
// the project base dir (next to the executable), NOT the browsable workspace root
// (dataDir) — the signing key must never be listable/downloadable via the file
@@ -115,12 +138,19 @@ func (s *Server) requireAuth(h http.Handler) http.Handler {
}
// GET /api/auth/status — reports whether the admin password has been initialised.
// 读失败必须回 503 而不是 initialized:false:前端在 initialized:false 时会把用户
// 送到 /setup 去设置密码(login/page.tsx),把数据库故障包装成 200 等于把用户往
// 覆盖已有密码的路上推。
func (s *Server) authStatus(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
hash, _, _ := pg.GetSetting(authPassKey)
hash, _, err := pg.GetSetting(authPassKey)
if err != nil {
writeErr(w, 503, errDataSourceUnavailable)
return
}
writeJSON(w, 200, map[string]any{"initialized": hash != ""})
}
@@ -130,7 +160,11 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
if pg == nil {
return
}
existing, _, _ := pg.GetSetting(authPassKey)
existing, _, err := pg.GetSetting(authPassKey)
if err != nil {
writeErr(w, 503, errDataSourceUnavailable)
return
}
if existing != "" {
writeErr(w, 403, "密码已设置")
return
@@ -142,15 +176,27 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "密码不能为空")
return
}
if msg := validatePassword(req.Password); msg != "" {
writeErr(w, 400, msg)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
writeErr(w, 500, "密码加密失败")
return
}
if err := pg.SetSetting(authPassKey, string(hash)); err != nil {
// 用 INSERT ... ON CONFLICT DO NOTHING 而不是 upsert:上面那次 GetSetting 只是
// 快速失败路径,真正"仅首次可设"的保证落在主键约束上。bcrypt 要跑几十毫秒,
// 这期间别的请求完全可能先把密码设好,而读检查本身也可能因故障而失效。
inserted, err := pg.InsertSettingIfAbsent(authPassKey, string(hash))
if err != nil {
writeErr(w, 500, "保存失败: "+err.Error())
return
}
if !inserted {
writeErr(w, 403, "密码已设置")
return
}
tok, err := signJWT(s.jwtKey)
if err != nil {
writeErr(w, 500, "token 生成失败")
@@ -183,7 +229,15 @@ func (s *Server) authChangePassword(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "新密码不能为空")
return
}
hash, ok, _ := pg.GetSetting(authPassKey)
if msg := validatePassword(req.NewPassword); msg != "" {
writeErr(w, 400, msg)
return
}
hash, ok, err := pg.GetSetting(authPassKey)
if err != nil {
writeErr(w, 503, errDataSourceUnavailable)
return
}
if !ok || hash == "" {
writeErr(w, 403, "密码未初始化,请先设置密码")
return
@@ -222,7 +276,11 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
writeErr(w, 401, "用户名或密码错误")
return
}
hash, ok, _ := pg.GetSetting(authPassKey)
hash, ok, err := pg.GetSetting(authPassKey)
if err != nil {
writeErr(w, 503, errDataSourceUnavailable)
return
}
if !ok || hash == "" {
writeErr(w, 403, "密码未初始化,请先设置密码")
return