package httpapi import ( "context" "crypto/rand" "crypto/subtle" "encoding/hex" "html/template" "mime" "net" "net/http" "net/url" "strings" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "git.pcux.cn/xuans/Scert/backend/internal/admin" "git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/passwordpolicy" "git.pcux.cn/xuans/Scert/backend/internal/repository" ) type resetPageData struct { CSRFToken string Message string Success bool } func (s *Server) currentResetSession() (admin.State, bool) { state, err := admin.ReadState(s.resetStatePath) if err != nil || !state.Active || state.CodeHash == "" || !s.now().UTC().Before(state.ExpiresAt) { return admin.State{}, false } if state.CodeHash == s.resetClosedHash { return admin.State{}, false } return state, true } func newCSRFToken() (string, error) { buffer := make([]byte, 32) if _, err := rand.Read(buffer); err != nil { return "", err } return hex.EncodeToString(buffer), nil } func (s *Server) setResetCSRFCookie(c *gin.Context, token string, expiresAt time.Time) { maxAge := int(expiresAt.Sub(s.now().UTC()).Seconds()) if maxAge < 1 { maxAge = 1 } c.SetSameSite(http.SameSiteStrictMode) c.SetCookie(resetCSRFTokenName, token, maxAge, admin.ResetPath, "", resetCookieSecure(c.Request), true) } func resetCookieSecure(request *http.Request) bool { if request == nil { return true } if request.TLS != nil { return true } host := request.Host if parsedHost, _, err := net.SplitHostPort(request.Host); err == nil { host = parsedHost } host = strings.Trim(host, "[]") return host != "localhost" && host != "127.0.0.1" && host != "::1" } func (s *Server) resetSubmit(c *gin.Context) { s.resetMu.Lock() defer s.resetMu.Unlock() session, ok := s.currentResetSession() if !ok { c.Status(http.StatusNotFound) return } ip := resetClientIP(c.Request) now := s.now().UTC() if !s.resetLimiter.allowed(ip, now) { s.recordResetAudit(c, "blocked", "密码重置请求超过失败频率限制") c.Status(http.StatusTooManyRequests) return } if !s.sameOrigin(c.Request) { s.resetLimiter.failed(ip, now) s.recordResetAudit(c, "rejected", "密码重置请求来源校验失败") s.writeResetFailure(c, http.StatusForbidden, "请求来源校验失败,请从重置页面重新提交") return } mediaType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type")) if err != nil || mediaType != "application/x-www-form-urlencoded" { s.resetLimiter.failed(ip, now) s.recordResetAudit(c, "rejected", "密码重置请求类型无效") s.writeResetFailure(c, http.StatusBadRequest, "请求格式无效,请从重置页面重新提交") return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, resetMaxBodyBytes) if err := c.Request.ParseForm(); err != nil { s.resetLimiter.failed(ip, now) s.recordResetAudit(c, "rejected", "密码重置请求内容无效") s.writeResetFailure(c, http.StatusBadRequest, "请求内容无效,请稍后重试") return } token := c.Request.PostForm.Get("csrf_token") cookie, err := c.Request.Cookie(resetCSRFTokenName) if err != nil || subtle.ConstantTimeCompare([]byte(token), []byte(cookie.Value)) != 1 || token == "" { s.resetLimiter.failed(ip, now) s.recordResetAudit(c, "rejected", "密码重置 CSRF 校验失败") s.writeResetFailure(c, http.StatusForbidden, "安全校验失败,请刷新页面后重试") return } code := c.Request.PostForm.Get("reset_code") verified, err := admin.VerifyCode(s.resetStatePath, code, now) if err != nil || verified.CodeHash != session.CodeHash { s.resetLimiter.failed(ip, now) s.recordResetAudit(c, "failed", "密码重置校验码无效或已失效") s.writeResetFailure(c, http.StatusBadRequest, "重置校验失败,请检查一次性校验码") return } password := c.Request.PostForm.Get("new_password") confirmation := c.Request.PostForm.Get("confirm_password") if !s.tlsMu.TryLock() { s.writeResetFailure(c, http.StatusConflict, "管理证书或配置正在更新,请稍后重试") return } defer s.tlsMu.Unlock() s.configMu.RLock() cfg := s.config s.configMu.RUnlock() if password != confirmation { s.recordResetAudit(c, "failed", "密码重置两次输入不一致") s.renderResetPage(c, http.StatusBadRequest, token, "两次输入的新密码不一致") return } if err := passwordpolicy.Validate(password, cfg.Admin.Username); err != nil { s.recordResetAudit(c, "failed", "密码重置新密码不符合安全规则") s.renderResetPage(c, http.StatusBadRequest, token, err.Error()) return } previousConfig := cfg hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { s.recordResetAudit(c, "error", "生成新密码摘要失败") s.writeResetFailure(c, http.StatusInternalServerError, "暂时无法保存新密码,请稍后重试") return } cfg.Admin.PasswordHash = string(hash) if err := s.syncPrimarySuperAdmin(c.Request.Context(), cfg); err != nil { s.recordResetAudit(c, "error", "同步超级管理员用户失败") s.writeResetFailure(c, http.StatusInternalServerError, "暂时无法同步管理员账户,请稍后重试") return } if strings.TrimSpace(s.configPath) == "" { _ = s.syncPrimarySuperAdmin(c.Request.Context(), previousConfig) s.recordResetAudit(c, "error", "密码重置配置路径未配置") s.writeResetFailure(c, http.StatusInternalServerError, "服务配置路径未就绪,请联系管理员") return } if err := cfg.Save(s.configPath); err != nil { if restoreErr := s.syncPrimarySuperAdmin(c.Request.Context(), previousConfig); restoreErr != nil && s.logger != nil { s.logger.Error("配置保存失败后恢复超级管理员用户摘要失败", "error", restoreErr) } s.recordResetAudit(c, "error", "保存密码重置配置失败") s.writeResetFailure(c, http.StatusInternalServerError, "暂时无法保存新密码,请稍后重试") return } s.invalidateUserSessions("primary-super-admin") if err := admin.Consume(s.resetStatePath, verified, now); err != nil { s.resetClosedHash = session.CodeHash s.configMu.Lock() s.config = cfg s.configMu.Unlock() s.recordResetAudit(c, "error", "新密码已保存但重置入口关闭失败") s.clearResetCookie(c) s.renderResetSuccess(c, "密码已更新,但重置入口关闭状态需要由 Scert-admin 停止后完成") return } s.configMu.Lock() s.config = cfg s.configMu.Unlock() s.resetClosedHash = session.CodeHash s.resetLimiter.clear(ip) s.recordResetAudit(c, "success", "超级管理员密码重置成功") s.clearResetCookie(c) s.renderResetSuccess(c, "密码重置成功,请停止 Scert-admin 以结束本次维护窗口") } func (s *Server) syncPrimarySuperAdmin(ctx context.Context, cfg config.Config) error { if s.users == nil { return nil } return s.users.EnsureSuperAdmin(ctx, repository.User{ ID: "primary-super-admin", Username: cfg.Admin.Username, Email: cfg.Admin.Email, PasswordHash: cfg.Admin.PasswordHash, }) } func (s *Server) sameOrigin(request *http.Request) bool { if request == nil || strings.TrimSpace(request.Host) == "" { return false } scheme := "http" if s.requestIsHTTPS(request) { scheme = "https" } origin := strings.TrimSpace(request.Header.Get("Origin")) if origin == "" { origin = strings.TrimSpace(request.Header.Get("Referer")) } if origin == "" { return false } parsed, err := url.Parse(origin) if err != nil || parsed.Host == "" || !strings.EqualFold(parsed.Scheme, scheme) { return false } return strings.EqualFold(parsed.Host, request.Host) } func (s *Server) clearResetCookie(c *gin.Context) { c.SetSameSite(http.SameSiteStrictMode) c.SetCookie(resetCSRFTokenName, "", -1, admin.ResetPath, "", resetCookieSecure(c.Request), true) } func (s *Server) writeResetFailure(c *gin.Context, status int, message string) { token := "" if cookie, err := c.Request.Cookie(resetCSRFTokenName); err == nil { token = cookie.Value } s.renderResetPage(c, status, token, message) } func (s *Server) renderResetPage(c *gin.Context, status int, token, message string) { s.setResetHeaders(c) data := resetPageData{CSRFToken: token, Message: message} c.Status(status) if err := resetTemplate.Execute(c.Writer, data); err != nil && s.logger != nil { s.logger.Error("渲染密码重置页面失败", "error", err) } } func (s *Server) renderResetSuccess(c *gin.Context, message string) { s.setResetHeaders(c) if err := resetSuccessTemplate.Execute(c.Writer, resetPageData{Message: message, Success: true}); err != nil && s.logger != nil { s.logger.Error("渲染密码重置成功页面失败", "error", err) } c.Status(http.StatusOK) } func (s *Server) setResetHeaders(c *gin.Context) { c.Header("Cache-Control", "no-store, no-cache, must-revalidate") c.Header("Pragma", "no-cache") c.Header("X-Robots-Tag", "noindex, nofollow, noarchive") c.Header("Referrer-Policy", "no-referrer") c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") c.Header("Content-Type", "text/html; charset=utf-8") } func (s *Server) recordResetAudit(c *gin.Context, status, message string) { if s.audit == nil { return } err := s.audit.CreateAuditLog(c.Request.Context(), repository.AuditLog{ Action: "admin_password_reset", ResourceType: "super_admin", ResourceID: "super-admin", Status: status, Message: message, CreatedAt: s.now().UTC(), }) if err != nil && s.logger != nil { s.logger.Warn("写入密码重置审计日志失败", "error", err) } } var resetTemplate = template.Must(template.New("reset").Parse(` Scert 超级管理员密码重置

超级管理员密码重置

本入口由 Scert-admin 临时开启,仅允许使用一次性校验码重置密码。

{{if .Message}}
{{.Message}}
{{end}}
`)) var resetSuccessTemplate = template.Must(template.New("reset-success").Parse(`Scert 密码重置完成

密码重置完成

{{.Message}}

`))