193 lines
5.6 KiB
Go
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"
|
|
}
|