mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
299 lines
9.2 KiB
Go
299 lines
9.2 KiB
Go
package server
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"fmt"
|
||
"log"
|
||
"math/big"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/golang-jwt/jwt/v5"
|
||
"golang.org/x/crypto/bcrypt"
|
||
)
|
||
|
||
const (
|
||
jwtKeyFilename = "jwt.key"
|
||
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
|
||
// manager. Legacy installs kept it at dataDir/jwt.key; if present there and not yet
|
||
// at the new location, it is migrated (key preserved, so sessions stay valid) and
|
||
// the old file removed so it disappears from the workspace. On first run a random
|
||
// key is generated and persisted.
|
||
func loadOrCreateJWTKey(keyDir, dataDir string) ([]byte, error) {
|
||
path := filepath.Join(keyDir, jwtKeyFilename)
|
||
// one-time migration out of the old in-workspace location.
|
||
if legacy := filepath.Join(dataDir, jwtKeyFilename); legacy != path {
|
||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||
if data, rerr := os.ReadFile(legacy); rerr == nil {
|
||
if werr := os.WriteFile(path, data, 0o600); werr == nil {
|
||
_ = os.Remove(legacy)
|
||
log.Printf("[auth] JWT key 已从 %s 迁移到 %s(移出可浏览工作区)", legacy, path)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if data, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(data))) >= 32 {
|
||
return []byte(strings.TrimSpace(string(data))), nil
|
||
}
|
||
buf := make([]byte, 32)
|
||
for i := range buf {
|
||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(keyChars))))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("generate jwt key: %w", err)
|
||
}
|
||
buf[i] = keyChars[n.Int64()]
|
||
}
|
||
if err := os.WriteFile(path, buf, 0600); err != nil {
|
||
return nil, fmt.Errorf("write jwt key: %w", err)
|
||
}
|
||
log.Printf("[auth] 新 JWT key 已写入 %s", path)
|
||
return buf, nil
|
||
}
|
||
|
||
// signJWT issues a 7-day HS256 token for user ARTEX.
|
||
func signJWT(key []byte) (string, error) {
|
||
return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
|
||
Subject: "ARTEX",
|
||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(jwtTTL)),
|
||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||
}).SignedString(key)
|
||
}
|
||
|
||
// verifyJWT returns true when tokenStr is a valid, non-expired HS256 token.
|
||
func verifyJWT(tokenStr string, key []byte) bool {
|
||
t, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
|
||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||
return nil, fmt.Errorf("unexpected signing method")
|
||
}
|
||
return key, nil
|
||
})
|
||
return err == nil && t.Valid
|
||
}
|
||
|
||
// extractToken reads the JWT from Authorization: Bearer header,
|
||
// artex_token cookie, or ?token= query param (for SSE connections).
|
||
func extractToken(r *http.Request) string {
|
||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||
return strings.TrimPrefix(h, "Bearer ")
|
||
}
|
||
if c, err := r.Cookie("artex_token"); err == nil && c.Value != "" {
|
||
return c.Value
|
||
}
|
||
return r.URL.Query().Get("token")
|
||
}
|
||
|
||
// requireAuth wraps h with JWT validation.
|
||
// /api/auth/*, /api/health and the Basic Auth gate check are exempt.
|
||
func (s *Server) requireAuth(h http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
p := r.URL.Path
|
||
if strings.HasPrefix(p, "/api/auth/") || p == "/api/health" || p == "/api/basic-auth/check" {
|
||
h.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
tok := extractToken(r)
|
||
if tok == "" {
|
||
writeErr(w, 401, "未授权")
|
||
return
|
||
}
|
||
if !verifyJWT(tok, s.jwtKey) {
|
||
writeErr(w, 401, "token 无效或已过期")
|
||
return
|
||
}
|
||
h.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// 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, _, err := pg.GetSetting(authPassKey)
|
||
if err != nil {
|
||
writeErr(w, 503, errDataSourceUnavailable)
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"initialized": hash != ""})
|
||
}
|
||
|
||
// POST /api/auth/init — sets the password for the first time; rejected if already set.
|
||
func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
|
||
pg := s.pg(w)
|
||
if pg == nil {
|
||
return
|
||
}
|
||
existing, _, err := pg.GetSetting(authPassKey)
|
||
if err != nil {
|
||
writeErr(w, 503, errDataSourceUnavailable)
|
||
return
|
||
}
|
||
if existing != "" {
|
||
writeErr(w, 403, "密码已设置")
|
||
return
|
||
}
|
||
var req struct {
|
||
Password string `json:"password"`
|
||
}
|
||
if err := decode(r, &req); err != nil || req.Password == "" {
|
||
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
|
||
}
|
||
// 用 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 生成失败")
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"token": tok})
|
||
}
|
||
|
||
// POST /api/auth/change-password — changes the admin password. Requires a valid
|
||
// token (this route is under /api/auth/* which requireAuth exempts, so the token
|
||
// is validated here) AND the current password.
|
||
func (s *Server) authChangePassword(w http.ResponseWriter, r *http.Request) {
|
||
pg := s.pg(w)
|
||
if pg == nil {
|
||
return
|
||
}
|
||
if !verifyJWT(extractToken(r), s.jwtKey) {
|
||
writeErr(w, 401, "未授权")
|
||
return
|
||
}
|
||
var req struct {
|
||
OldPassword string `json:"old_password"`
|
||
NewPassword string `json:"new_password"`
|
||
}
|
||
if err := decode(r, &req); err != nil {
|
||
writeErr(w, 400, "请求格式错误")
|
||
return
|
||
}
|
||
if req.NewPassword == "" {
|
||
writeErr(w, 400, "新密码不能为空")
|
||
return
|
||
}
|
||
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
|
||
}
|
||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.OldPassword)); err != nil {
|
||
writeErr(w, 401, "当前密码错误")
|
||
return
|
||
}
|
||
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
writeErr(w, 500, "密码加密失败")
|
||
return
|
||
}
|
||
if err := pg.SetSetting(authPassKey, string(newHash)); err != nil {
|
||
writeErr(w, 500, "保存失败: "+err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"ok": true})
|
||
}
|
||
|
||
// POST /api/auth/login — validates username/password and returns a JWT.
|
||
func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
||
pg := s.pg(w)
|
||
if pg == nil {
|
||
return
|
||
}
|
||
var req struct {
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
}
|
||
if err := decode(r, &req); err != nil {
|
||
writeErr(w, 400, "请求格式错误")
|
||
return
|
||
}
|
||
if req.Username != "ARTEX" {
|
||
writeErr(w, 401, "用户名或密码错误")
|
||
return
|
||
}
|
||
hash, ok, err := pg.GetSetting(authPassKey)
|
||
if err != nil {
|
||
writeErr(w, 503, errDataSourceUnavailable)
|
||
return
|
||
}
|
||
if !ok || hash == "" {
|
||
writeErr(w, 403, "密码未初始化,请先设置密码")
|
||
return
|
||
}
|
||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil {
|
||
writeErr(w, 401, "用户名或密码错误")
|
||
return
|
||
}
|
||
tok, err := signJWT(s.jwtKey)
|
||
if err != nil {
|
||
writeErr(w, 500, "token 生成失败")
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"token": tok})
|
||
}
|