166 lines
5.2 KiB
Go
166 lines
5.2 KiB
Go
// 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
|
|
}
|