193 lines
5.6 KiB
Go

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"
}