package server import ( "crypto/rand" "fmt" "log" "math/big" "net/http" "os" "path/filepath" "strings" "time" "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" ) // 인증 엔드포인트가 HTTP 응답으로 돌려주는 사용자 노출 문구다. 한국어 UI 에서 로그인· // 비밀번호 설정이 실패하면 이 문구가 그대로 토스트로 뜨므로 한국어로 둔다. 자격 증명 // 오류 문구는 로그인 화면(web messages auth.login.errorCredential)과 표기를 맞췄다. // token 은 기술 용어라 원문 그대로 둔다(로그·주석은 BRIEF 방침상 최하위라 손대지 않음). const ( authErrUnauthorized = "需要认证" authErrTokenInvalid = "token 无效或已过期" authErrPasswordAlreadySet = "密码已设置" authErrPasswordEmpty = "请输入密码" authErrNewPasswordEmpty = "请输入新密码" authErrPasswordHash = "密码加密失败" authErrSaveFailedPrefix = "保存失败: " authErrTokenGen = "token 生成失败" authErrBadRequest = "请求格式无效" authErrPasswordNotInit = "密码尚未初始化,请先设置密码" authErrCurrentPasswordWrong = "当前密码不正确" authErrBadCredential = "用户名或密码不正确" ) // 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 密钥从 %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 密钥写入 %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/* and /api/health 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" { h.ServeHTTP(w, r) return } tok := extractToken(r) if tok == "" { writeErr(w, 401, authErrUnauthorized) return } if !verifyJWT(tok, s.jwtKey) { writeErr(w, 401, authErrTokenInvalid) return } h.ServeHTTP(w, r) }) } // GET /api/auth/status — reports whether the admin password has been initialised. func (s *Server) authStatus(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { return } hash, _, _ := pg.GetSetting(authPassKey) 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, _, _ := pg.GetSetting(authPassKey) if existing != "" { writeErr(w, 403, authErrPasswordAlreadySet) return } var req struct { Password string `json:"password"` } if err := decode(r, &req); err != nil || req.Password == "" { writeErr(w, 400, authErrPasswordEmpty) return } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { writeErr(w, 500, authErrPasswordHash) return } if err := pg.SetSetting(authPassKey, string(hash)); err != nil { writeErr(w, 500, authErrSaveFailedPrefix+err.Error()) return } tok, err := signJWT(s.jwtKey) if err != nil { writeErr(w, 500, authErrTokenGen) 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, authErrUnauthorized) return } var req struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` } if err := decode(r, &req); err != nil { writeErr(w, 400, authErrBadRequest) return } if req.NewPassword == "" { writeErr(w, 400, authErrNewPasswordEmpty) return } hash, ok, _ := pg.GetSetting(authPassKey) if !ok || hash == "" { writeErr(w, 403, authErrPasswordNotInit) return } if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.OldPassword)); err != nil { writeErr(w, 401, authErrCurrentPasswordWrong) return } newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { writeErr(w, 500, authErrPasswordHash) return } if err := pg.SetSetting(authPassKey, string(newHash)); err != nil { writeErr(w, 500, authErrSaveFailedPrefix+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, authErrBadRequest) return } if req.Username != "ARTEX" { writeErr(w, 401, authErrBadCredential) return } hash, ok, _ := pg.GetSetting(authPassKey) if !ok || hash == "" { writeErr(w, 403, authErrPasswordNotInit) return } if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil { writeErr(w, 401, authErrBadCredential) return } tok, err := signJWT(s.jwtKey) if err != nil { writeErr(w, 500, authErrTokenGen) return } writeJSON(w, 200, map[string]any{"token": tok}) }