package externaladmin import ( "crypto/subtle" "errors" "net/http" "strings" "time" "hyapp-admin-server/internal/appctx" adminmiddleware "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/response" "hyapp-admin-server/internal/security" "github.com/gin-gonic/gin" ) const ( contextPrincipal = "externalAdminPrincipal" contextCSRFToken = "externalAdminCSRFToken" ) func (h *Handler) AuthRequired() gin.HandlerFunc { return func(c *gin.Context) { cookie, err := c.Request.Cookie(SessionCookieName) if err != nil || strings.TrimSpace(cookie.Value) == "" { response.Unauthorized(c, "缺少外管会话") c.Abort() return } principal, err := h.service.Authenticate(c.Request.Context(), cookie.Value) if err != nil { if errors.Is(err, ErrInvalidSession) { h.clearSessionCookies(c) response.Unauthorized(c, "外管会话已失效") } else { response.ServerError(c, "外管会话服务暂不可用") } c.Abort() return } // The session is the tenant authority. A caller may omit X-App-Code, but it can never // switch tenants by sending a header that differs from the App fixed at login. headerAppCode := strings.ToLower(strings.TrimSpace(c.GetHeader(appctx.HeaderAppCode))) if headerAppCode != "" && headerAppCode != principal.AppCode { response.Forbidden(c, "请求 App 与外管会话不一致") c.Abort() return } c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), principal.AppCode)) c.Header(appctx.HeaderAppCode, principal.AppCode) c.Set(contextPrincipal, principal) // Existing business handlers forward these generic actor fields to owner services. Use a // collision-free synthetic identity there; the real account remains in the external audit log. c.Set(adminmiddleware.ContextUserID, principal.OperatorID) c.Set(adminmiddleware.ContextUsername, operatorUsername(principal.AppCode, principal.Username)) c.Set(adminmiddleware.ContextPermissions, principal.Permissions) // The raw CSRF secret is never persisted server-side. Echo it only when the browser's // double-submit cookie still matches the hash stored in this exact session. if csrfCookie, cookieErr := c.Request.Cookie(CSRFCookieName); cookieErr == nil && secureEqualHash(csrfCookie.Value, principal.CSRFTokenHash) { c.Set(contextCSRFToken, csrfCookie.Value) } c.Next() } } func (h *Handler) RequireCSRF() gin.HandlerFunc { return func(c *gin.Context) { if isSafeMethod(c.Request.Method) { c.Next() return } principal, ok := CurrentPrincipal(c) if !ok { response.Unauthorized(c, "外管会话已失效") c.Abort() return } csrfCookie, cookieErr := c.Request.Cookie(CSRFCookieName) headerToken := strings.TrimSpace(c.GetHeader(CSRFHeaderName)) if cookieErr != nil || headerToken == "" || !secureEqual(csrfCookie.Value, headerToken) || !secureEqualHash(headerToken, principal.CSRFTokenHash) { response.Forbidden(c, "CSRF 校验失败") c.Abort() return } c.Set(contextCSRFToken, headerToken) c.Next() } } func (h *Handler) RequirePasswordChanged() gin.HandlerFunc { return func(c *gin.Context) { principal, ok := CurrentPrincipal(c) if !ok { response.Unauthorized(c, "外管会话已失效") c.Abort() return } if principal.PasswordChangeRequired { response.Forbidden(c, "请先修改初始密码") c.Abort() return } c.Next() } } func (h *Handler) Audit() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() if isSafeMethod(c.Request.Method) { return } principal, ok := CurrentPrincipal(c) if !ok { return } status := "success" if c.Writer.Status() >= http.StatusBadRequest { status = "failed" } fullPath := c.FullPath() if fullPath == "" { fullPath = c.Request.URL.Path } _ = h.service.CreateOperationLog(c.Request.Context(), model.ExternalAdminOperationLog{ AccountID: principal.AccountID, AppCode: principal.AppCode, Username: principal.Username, RequestID: adminmiddleware.CurrentRequestID(c), Action: strings.ToLower(c.Request.Method) + " " + fullPath, Resource: externalAuditResource(fullPath), ResourceID: externalAuditResourceID(c), Method: c.Request.Method, Path: c.Request.URL.Path, IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), Status: status, HTTPStatus: c.Writer.Status(), LatencyMS: time.Since(start).Milliseconds(), }) } } func CurrentPrincipal(c *gin.Context) (SessionPrincipal, bool) { value, ok := c.Get(contextPrincipal) if !ok { return SessionPrincipal{}, false } principal, ok := value.(SessionPrincipal) return principal, ok } func CurrentCSRFToken(c *gin.Context) string { value, _ := c.Get(contextCSRFToken) token, _ := value.(string) return token } func secureEqual(left string, right string) bool { if len(left) == 0 || len(left) != len(right) { return false } return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1 } func secureEqualHash(raw string, expectedHash string) bool { return secureEqual(security.HashToken(raw), expectedHash) } func isSafeMethod(method string) bool { switch method { case http.MethodGet, http.MethodHead, http.MethodOptions: return true default: return false } } func externalAuditResourceID(c *gin.Context) string { for _, name := range []string{"id", "user_id", "room_id", "resource_id", "grant_id", "pretty_id", "banner_id"} { if value := c.Param(name); value != "" { return value } } return "" } func externalAuditResource(path string) string { parts := strings.Split(strings.Trim(path, "/"), "/") for index, part := range parts { if part == "external" && index+1 < len(parts) { return parts[index+1] } } return "external-admin" }