// Package server 定义采购服务当前拥有的 HTTP 端点。 package server import ( "crypto/subtle" "errors" "net/http" "net/url" "strings" "cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/transport/webui" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" ) const maxFormBytes = 8 << 10 // Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。 type Options struct { AdminUsername string AdminPasswordBcrypt string Sessions *auth.Manager } // NewRouter 返回当前服务范围内的完整 HTTP 路由。 func NewRouter(options Options) (*gin.Engine, error) { if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil { return nil, errors.New("server authentication options are incomplete") } router := gin.New() router.Use(gin.Recovery()) router.Use(securityHeaders()) router.GET("/healthz", healthz) router.GET("/login", loginPage(options)) router.POST("/login", login(options)) router.POST("/logout", logout(options)) router.GET("/tasks", tasksPage(options)) return router, nil } func healthz(context *gin.Context) { context.JSON(http.StatusOK, gin.H{"status": "ok"}) } func securityHeaders() gin.HandlerFunc { return func(context *gin.Context) { context.Header("Cache-Control", "no-store") context.Header("X-Content-Type-Options", "nosniff") context.Header("Referrer-Policy", "no-referrer") context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") context.Next() } } func loginPage(options Options) gin.HandlerFunc { return func(context *gin.Context) { csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request) if authenticated { context.Redirect(http.StatusSeeOther, "/tasks") return } renderLogin(context, http.StatusOK, csrfToken, returnTo(context.Query("return_to")), "", "") } } func login(options Options) gin.HandlerFunc { return func(context *gin.Context) { limitFormBody(context) csrfToken := context.PostForm("csrf_token") returnPath := returnTo(context.PostForm("return_to")) username := context.PostForm("username") password := context.PostForm("password") if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok { newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request) renderLogin(context, http.StatusForbidden, newCSRF, returnPath, "", "请求已过期,请重新登录。") return } usernameMatches := subtle.ConstantTimeCompare([]byte(options.AdminUsername), []byte(username)) == 1 passwordMatches := bcrypt.CompareHashAndPassword([]byte(options.AdminPasswordBcrypt), []byte(password)) == nil if !usernameMatches || !passwordMatches { csrf, _ := options.Sessions.Ensure(context.Writer, context.Request) renderLogin(context, http.StatusUnauthorized, csrf, returnPath, "", "账号或密码不正确,请检查后重试。") return } options.Sessions.RotateAuthenticated(context.Writer, context.Request) context.Redirect(http.StatusSeeOther, returnPath) } } func logout(options Options) gin.HandlerFunc { return func(context *gin.Context) { limitFormBody(context) authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.PostForm("csrf_token")) if !ok || !authenticated { context.Status(http.StatusForbidden) return } options.Sessions.Logout(context.Writer, context.Request) context.Redirect(http.StatusSeeOther, "/login") } } func tasksPage(options Options) gin.HandlerFunc { return func(context *gin.Context) { csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request) if !authenticated { context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI())) return } context.Header("Content-Type", "text/html; charset=utf-8") if err := webui.RenderTasks(context.Writer, webui.TasksData{CSRFToken: csrfToken}); err != nil { _ = context.Error(err) } } } func renderLogin(context *gin.Context, status int, csrfToken, returnPath, username, message string) { context.Header("Content-Type", "text/html; charset=utf-8") context.Status(status) if err := webui.RenderLogin(context.Writer, webui.LoginData{ CSRFToken: csrfToken, ReturnTo: returnPath, Username: username, Error: message, }); err != nil { _ = context.Error(err) } } func limitFormBody(context *gin.Context) { context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes) } func returnTo(value string) string { if value == "/tasks" || strings.HasPrefix(value, "/tasks/") || strings.HasPrefix(value, "/tasks?") { if strings.Contains(value, "\\") || strings.Contains(value, "%") || strings.HasPrefix(value, "//") { return "/tasks" } parsed, err := url.ParseRequestURI(value) if err == nil && parsed.IsAbs() == false && parsed.Host == "" && hasSafeTaskPath(parsed.Path) { return value } } return "/tasks" } func hasSafeTaskPath(path string) bool { for _, segment := range strings.Split(path, "/") { if segment == "." || segment == ".." { return false } } return true }