init
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
"goravel/app/models"
|
||||
"goravel/app/utils"
|
||||
)
|
||||
|
||||
func Blacklist() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
// 排除登录接口,避免管理员被封禁后无法登录
|
||||
path := ctx.Request().Path()
|
||||
if path == "/api/admin/login" || path == "/api/admin/login/captcha" {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取真实IP地址
|
||||
realIP := helpers.GetRealIP(ctx)
|
||||
|
||||
// 查询所有启用的黑名单记录
|
||||
var blacklists []models.Blacklist
|
||||
if err := facades.Orm().Query().Where("status", 1).Get(&blacklists); err != nil {
|
||||
// 如果查询失败,记录错误但继续处理请求(避免影响系统正常运行)
|
||||
facades.Log().Errorf("Blacklist middleware: Failed to query blacklists: %v", err)
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查IP是否在黑名单中
|
||||
for _, blacklist := range blacklists {
|
||||
if utils.IsIPInBlacklist(realIP, blacklist.IP) {
|
||||
// IP在黑名单中,拒绝访问
|
||||
facades.Log().Warningf("Blacklist middleware: IP %s blocked by blacklist ID %d", realIP, blacklist.ID)
|
||||
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
|
||||
"code": http.StatusForbidden,
|
||||
"message": trans.Get(ctx, "ip_blocked"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// IP不在黑名单中,继续处理请求
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
)
|
||||
|
||||
// Cors CORS 中间件,处理跨域请求
|
||||
func Cors() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
// 获取请求路径
|
||||
path := ctx.Request().Path()
|
||||
|
||||
// 检查是否是 WebSocket 升级请求
|
||||
isWebSocket := strings.ToLower(ctx.Request().Header("Upgrade", "")) == "websocket" ||
|
||||
strings.ToLower(ctx.Request().Header("Connection", "")) == "upgrade"
|
||||
|
||||
// 获取 CORS 配置的路径列表
|
||||
corsPaths := facades.Config().Get("cors.paths", []string{}).([]string)
|
||||
|
||||
// 检查当前路径是否需要 CORS 处理
|
||||
needCors := false
|
||||
if len(corsPaths) == 0 {
|
||||
// 如果没有配置路径,默认对所有路径启用
|
||||
needCors = true
|
||||
} else {
|
||||
for _, corsPath := range corsPaths {
|
||||
// 支持通配符匹配
|
||||
if strings.HasSuffix(corsPath, "*") {
|
||||
prefix := strings.TrimSuffix(corsPath, "*")
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
needCors = true
|
||||
break
|
||||
}
|
||||
} else if path == corsPath {
|
||||
needCors = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket 请求直接放行,不需要 CORS 处理(WebSocket 有自己的协议)
|
||||
if isWebSocket {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
if !needCors {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 CORS 配置
|
||||
allowedOrigins := facades.Config().Get("cors.allowed_origins", []string{"*"}).([]string)
|
||||
allowedMethods := facades.Config().Get("cors.allowed_methods", []string{"*"}).([]string)
|
||||
allowedHeaders := facades.Config().Get("cors.allowed_headers", []string{"*"}).([]string)
|
||||
exposedHeaders := facades.Config().Get("cors.exposed_headers", []string{}).([]string)
|
||||
maxAge := facades.Config().GetInt("cors.max_age", 0)
|
||||
supportsCredentials := facades.Config().GetBool("cors.supports_credentials", false)
|
||||
|
||||
// 获取请求的 Origin
|
||||
origin := ctx.Request().Header("Origin", "")
|
||||
|
||||
// 检查是否允许该 Origin
|
||||
allowed := false
|
||||
var allowedOrigin string
|
||||
|
||||
if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" {
|
||||
// 允许所有源
|
||||
allowed = true
|
||||
allowedOrigin = "*"
|
||||
} else if origin != "" {
|
||||
// 检查是否在允许列表中
|
||||
if slices.Contains(allowedOrigins, origin) {
|
||||
allowed = true
|
||||
allowedOrigin = origin
|
||||
}
|
||||
}
|
||||
|
||||
// 处理预检请求 (OPTIONS) - 必须在设置其他头之前处理
|
||||
if ctx.Request().Method() == "OPTIONS" {
|
||||
// 对于预检请求,必须设置 CORS 头
|
||||
// 即使 origin 不在允许列表中,也要返回 CORS 头(只是不设置 Access-Control-Allow-Origin)
|
||||
// 这样浏览器才能正确判断,而不是因为状态码问题而失败
|
||||
|
||||
// 创建响应对象并设置所有 CORS 头
|
||||
response := ctx.Response()
|
||||
|
||||
if allowed && origin != "" {
|
||||
// Origin 在允许列表中
|
||||
response.Header("Access-Control-Allow-Origin", allowedOrigin)
|
||||
if supportsCredentials && allowedOrigin != "*" {
|
||||
response.Header("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
} else if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" {
|
||||
// 配置允许所有源
|
||||
response.Header("Access-Control-Allow-Origin", "*")
|
||||
} else if origin != "" {
|
||||
// Origin 不在允许列表中,不设置 Access-Control-Allow-Origin
|
||||
// 浏览器会拒绝请求,但至少不会因为状态码问题而失败
|
||||
}
|
||||
|
||||
// 设置允许的方法(对于预检请求,这些头必须设置)
|
||||
methodsStr := "*"
|
||||
if len(allowedMethods) > 0 && allowedMethods[0] != "*" {
|
||||
methodsStr = strings.Join(allowedMethods, ", ")
|
||||
}
|
||||
response.Header("Access-Control-Allow-Methods", methodsStr)
|
||||
|
||||
// 设置允许的请求头
|
||||
headersStr := "*"
|
||||
if len(allowedHeaders) > 0 && allowedHeaders[0] != "*" {
|
||||
headersStr = strings.Join(allowedHeaders, ", ")
|
||||
}
|
||||
response.Header("Access-Control-Allow-Headers", headersStr)
|
||||
|
||||
// 设置暴露的响应头
|
||||
if len(exposedHeaders) > 0 {
|
||||
response.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
|
||||
}
|
||||
|
||||
// 设置预检请求的缓存时间
|
||||
if maxAge > 0 {
|
||||
response.Header("Access-Control-Max-Age", strconv.Itoa(maxAge))
|
||||
}
|
||||
|
||||
// 返回 204 No Content 并终止请求处理
|
||||
// 对于 OPTIONS 请求,返回空响应体,状态码为 204
|
||||
// 使用 Json 方法返回空对象,然后调用 Abort() 终止请求
|
||||
_ = response.Json(http.StatusNoContent, http.Json{}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 对于非预检请求,设置 CORS 响应头
|
||||
if allowed && origin != "" {
|
||||
ctx.Response().Header("Access-Control-Allow-Origin", allowedOrigin)
|
||||
if supportsCredentials && allowedOrigin != "*" {
|
||||
ctx.Response().Header("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
} else if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" && origin != "" {
|
||||
// 如果配置允许所有源,且请求有 origin,设置 CORS 头
|
||||
ctx.Response().Header("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
|
||||
// 设置暴露的响应头(非预检请求)
|
||||
if len(exposedHeaders) > 0 {
|
||||
ctx.Response().Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
|
||||
}
|
||||
|
||||
// 继续处理请求
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
)
|
||||
|
||||
func DevelopmentOnly() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
env := facades.Config().Get("app.env", "production")
|
||||
if env != "local" && env != "development" {
|
||||
ctx.Response().Json(http.StatusForbidden, map[string]any{
|
||||
"code": 403,
|
||||
"message": "This feature is only available in development mode",
|
||||
})
|
||||
ctx.Request().Abort()
|
||||
return
|
||||
}
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
)
|
||||
|
||||
// Domain 域名验证中间件
|
||||
func Domain(configValueOrDomains ...any) http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
var domains []string
|
||||
|
||||
// 如果没有参数,不验证(允许所有域名)
|
||||
if len(configValueOrDomains) == 0 {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析配置值的辅助函数
|
||||
parseConfigValue := func(value any) []string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return v
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
// 分割逗号分隔的域名
|
||||
domainsList := strings.Split(v, ",")
|
||||
result := make([]string, 0, len(domainsList))
|
||||
for _, d := range domainsList {
|
||||
d = strings.TrimSpace(d)
|
||||
if d != "" {
|
||||
result = append(result, d)
|
||||
}
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if str, ok := item.(string); ok && str != "" {
|
||||
result = append(result, str)
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 处理参数
|
||||
for _, param := range configValueOrDomains {
|
||||
if param == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v := param.(type) {
|
||||
case []string:
|
||||
// 如果是字符串数组,直接使用
|
||||
if len(v) > 0 {
|
||||
domains = append(domains, v...)
|
||||
}
|
||||
case string:
|
||||
// 如果是字符串,可能是单个域名或配置键
|
||||
if v != "" {
|
||||
// 先尝试作为配置键读取
|
||||
configValue := facades.Config().Get(v, nil)
|
||||
if configValue != nil {
|
||||
// 如果配置存在,解析配置值
|
||||
parsedDomains := parseConfigValue(configValue)
|
||||
if len(parsedDomains) > 0 {
|
||||
domains = append(domains, parsedDomains...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 否则当作域名
|
||||
domains = append(domains, v)
|
||||
}
|
||||
case []any:
|
||||
// 如果是 any 数组,递归处理
|
||||
for _, item := range v {
|
||||
if str, ok := item.(string); ok && str != "" {
|
||||
domains = append(domains, str)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 其他类型,尝试解析为配置值
|
||||
parsedDomains := parseConfigValue(v)
|
||||
if len(parsedDomains) > 0 {
|
||||
domains = append(domains, parsedDomains...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有指定允许的域名,允许所有域名访问(直接放行)
|
||||
if len(domains) == 0 {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取请求的 Host
|
||||
// 优先从 X-Forwarded-Host 获取(适用于反向代理场景)
|
||||
host := ctx.Request().Header("X-Forwarded-Host", "")
|
||||
if host == "" {
|
||||
// 使用框架提供的 Host() 方法获取(推荐方式)
|
||||
host = ctx.Request().Host()
|
||||
}
|
||||
|
||||
// 如果 X-Forwarded-Host 包含多个值(逗号分隔),取第一个
|
||||
if host != "" && strings.Contains(host, ",") {
|
||||
host = strings.TrimSpace(strings.Split(host, ",")[0])
|
||||
}
|
||||
|
||||
// 调试日志:记录获取到的 Host 值
|
||||
if facades.Config().GetBool("app.debug", false) {
|
||||
facades.Log().Debugf("Domain middleware: Host detection - X-Forwarded-Host: %s, Host(): %s, Final host: %s",
|
||||
ctx.Request().Header("X-Forwarded-Host", ""),
|
||||
ctx.Request().Host(),
|
||||
host)
|
||||
}
|
||||
|
||||
// 规范化 Host(移除端口号,转换为小写)
|
||||
normalizedHost := normalizeHost(host)
|
||||
|
||||
// 调试日志:记录规范化后的 Host 和配置的域名
|
||||
if facades.Config().GetBool("app.debug", false) {
|
||||
facades.Log().Debugf("Domain middleware: Normalized host: %s, Configured domains: %v", normalizedHost, domains)
|
||||
}
|
||||
|
||||
// 检查是否在允许的域名列表中
|
||||
allowed := false
|
||||
var matchedDomain string
|
||||
for _, allowedDomain := range domains {
|
||||
normalizedAllowed := normalizeHost(allowedDomain)
|
||||
// 支持精确匹配和通配符匹配
|
||||
if normalizedHost == normalizedAllowed || matchDomain(normalizedHost, normalizedAllowed) {
|
||||
allowed = true
|
||||
matchedDomain = allowedDomain
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
// 域名不在允许列表中,拒绝访问
|
||||
// facades.Log().Warningf("Domain middleware: Access denied. Request host: %s (normalized: %s), Allowed domains: %v", host, normalizedHost, domains)
|
||||
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
|
||||
"code": http.StatusForbidden,
|
||||
"message": "Access denied: domain not allowed",
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 记录匹配的域名(仅在调试模式下)
|
||||
if facades.Config().GetBool("app.debug", false) {
|
||||
facades.Log().Debugf("Domain middleware: Access allowed. Request host: %s, Matched domain: %s", normalizedHost, matchedDomain)
|
||||
}
|
||||
|
||||
// 域名验证通过,继续处理请求
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeHost 规范化域名(移除端口号,转换为小写,去除前后空格)
|
||||
func normalizeHost(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 移除端口号
|
||||
if hostname, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = hostname
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
// matchDomain 域名匹配,支持通配符
|
||||
// 例如:*.example.com 可以匹配 a.example.com, b.example.com 等
|
||||
func matchDomain(host, pattern string) bool {
|
||||
if pattern == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模式以 * 开头,进行通配符匹配
|
||||
if after, ok := strings.CutPrefix(pattern, "*."); ok {
|
||||
// 移除 *.
|
||||
suffix := after
|
||||
// 检查 host 是否以 .suffix 结尾
|
||||
if strings.HasSuffix(host, "."+suffix) || host == suffix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 如果模式以 * 结尾,进行前缀匹配
|
||||
if before, ok := strings.CutSuffix(pattern, ".*"); ok {
|
||||
prefix := before
|
||||
if strings.HasPrefix(host, prefix+".") || host == prefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
"github.com/goravel/framework/support/str"
|
||||
|
||||
"goravel/app/http/trans"
|
||||
"goravel/app/models"
|
||||
"goravel/app/services"
|
||||
"goravel/app/utils/logger"
|
||||
)
|
||||
|
||||
// min 返回两个整数中的较小值
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func Jwt() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
// 如果路径是api/admin前缀,使用admin guard
|
||||
path := ctx.Request().Path()
|
||||
pathStr := str.Of(path)
|
||||
if pathStr.IsEmpty() || (!pathStr.StartsWith("/api/admin") && !pathStr.StartsWith("/admin")) {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
token := ctx.Request().Header("Authorization", "")
|
||||
|
||||
// 如果 Header 中没有 token,尝试从 URL 参数中获取(用于 SSE 等不支持自定义 headers 的场景)
|
||||
if str.Of(token).IsEmpty() {
|
||||
token = ctx.Request().Query("_token", "")
|
||||
}
|
||||
|
||||
if str.Of(token).IsEmpty() {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "not_logged_in"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 移除Bearer前缀(如果有)
|
||||
token = str.Of(token).ChopStart("Bearer ").Trim().String()
|
||||
|
||||
if token == "" {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "not_logged_in"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 从数据库查找token
|
||||
tokenService := services.NewTokenServiceImpl()
|
||||
accessToken, err := tokenService.FindToken(token)
|
||||
if err != nil {
|
||||
// token查找失败或已过期
|
||||
logger.ErrorfHTTP(ctx, "JWT middleware: FindToken error: %v, token prefix: %s", err, token[:min(20, len(token))])
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "invalid_token"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
if accessToken == nil {
|
||||
logger.ErrorfHTTP(ctx, "JWT middleware: accessToken is nil, token prefix: %s", token[:min(20, len(token))])
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "invalid_token"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查token类型
|
||||
if accessToken.TokenableType != "admin" {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "invalid_token"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 查询用户信息
|
||||
var admin models.Admin
|
||||
if err := facades.Orm().Query().Where("id", accessToken.TokenableID).First(&admin); err != nil {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "user_not_found"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 更新最后使用时间
|
||||
_ = tokenService.UpdateLastUsedAt(token)
|
||||
|
||||
// 滑动过期:如果token有过期时间,每次请求时自动延长过期时间
|
||||
if accessToken.ExpiresAt != nil {
|
||||
ttl := facades.Config().GetInt("jwt.ttl", 60) // 默认60分钟
|
||||
if ttl > 0 {
|
||||
newExpiresAt := time.Now().Add(time.Duration(ttl) * time.Minute)
|
||||
// 更新token的过期时间
|
||||
_, _ = facades.Orm().Query().
|
||||
Model(&models.PersonalAccessToken{}).
|
||||
Where("id", accessToken.ID).
|
||||
Update("expires_at", newExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// 将用户信息存储到context中,供后续中间件使用
|
||||
ctx.WithValue("admin", admin)
|
||||
ctx.WithValue("token", accessToken)
|
||||
|
||||
// facades.Log().Debugf("JWT middleware: admin set in context, ID: %d, Username: %s", admin.ID, admin.Username)
|
||||
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
httpcontract "github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
|
||||
"goravel/app/utils"
|
||||
)
|
||||
|
||||
// Lang 多语言中间件,从请求头获取语言
|
||||
func Lang() httpcontract.Middleware {
|
||||
return func(ctx httpcontract.Context) {
|
||||
// 使用通用工具函数获取语言
|
||||
lang := utils.GetCurrentLanguage(ctx)
|
||||
facades.App().SetLocale(ctx, lang)
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/opentracing/opentracing-go"
|
||||
"github.com/opentracing/opentracing-go/ext"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
)
|
||||
|
||||
const (
|
||||
OpentracingTracer = "opentracing_tracer"
|
||||
OpentracingCtx = "opentracing_ctx"
|
||||
)
|
||||
|
||||
func Opentracing(tracer opentracing.Tracer) http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
var parentSpan opentracing.Span
|
||||
|
||||
spCtx, err := opentracing.GlobalTracer().Extract(opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(ctx.Request().Headers()))
|
||||
if err != nil {
|
||||
parentSpan = tracer.StartSpan(ctx.Request().Path())
|
||||
defer parentSpan.Finish()
|
||||
} else {
|
||||
parentSpan = opentracing.StartSpan(
|
||||
ctx.Request().Path(),
|
||||
opentracing.ChildOf(spCtx),
|
||||
opentracing.Tag{Key: string(ext.Component), Value: "HTTP"},
|
||||
ext.SpanKindRPCServer,
|
||||
)
|
||||
defer parentSpan.Finish()
|
||||
}
|
||||
|
||||
ctx.WithValue(OpentracingTracer, tracer)
|
||||
ctx.WithValue(OpentracingCtx, opentracing.ContextWithSpan(context.Background(), parentSpan))
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/models"
|
||||
"goravel/app/services"
|
||||
"goravel/app/utils"
|
||||
"goravel/app/utils/logger"
|
||||
"goravel/app/utils/traceid"
|
||||
)
|
||||
|
||||
// OperationLog 操作日志中间件
|
||||
func OperationLog() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
systemLogService := services.NewSystemLogService()
|
||||
startTime := time.Now()
|
||||
|
||||
// 获取请求信息
|
||||
method := ctx.Request().Method()
|
||||
path := ctx.Request().Path()
|
||||
ip := ctx.Request().Ip()
|
||||
userAgent := ctx.Request().Header("User-Agent", "")
|
||||
|
||||
// 获取请求参数(排除敏感信息)
|
||||
var requestBody string
|
||||
if method == "POST" || method == "PUT" || method == "PATCH" {
|
||||
// 获取所有输入参数
|
||||
inputs := make(map[string]any)
|
||||
// 记录所有非敏感参数
|
||||
allInputs := ctx.Request().All()
|
||||
for key, value := range allInputs {
|
||||
// 使用工具函数检查是否是敏感字段
|
||||
if utils.IsSensitiveField(key) {
|
||||
inputs[key] = "***"
|
||||
} else {
|
||||
inputs[key] = value
|
||||
}
|
||||
}
|
||||
if data, err := json.Marshal(inputs); err == nil {
|
||||
requestBody = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取管理员ID(从JWT中间件设置的context中获取)
|
||||
var adminID uint
|
||||
if admin, err := helpers.GetAdminFromContext(ctx); err == nil {
|
||||
adminID = admin.ID
|
||||
}
|
||||
|
||||
// 继续处理请求
|
||||
ctx.Request().Next()
|
||||
|
||||
// 计算耗时
|
||||
duration := int(time.Since(startTime).Milliseconds())
|
||||
|
||||
// 只记录新增、修改、删除操作(POST、PUT、PATCH、DELETE),排除 GET 请求
|
||||
// 同时排除登录和info接口,以及分片上传的进度查询(GET请求)
|
||||
// 排除代码生成器相关操作
|
||||
// 对于分片上传,只记录 merge 操作(最终完成上传),排除 init 和 upload 操作
|
||||
if (method == "POST" || method == "PUT" || method == "PATCH" || method == "DELETE") &&
|
||||
path != "/api/admin/login" && path != "/api/admin/info" &&
|
||||
!strings.HasPrefix(path, "/api/admin/code-generator/") {
|
||||
|
||||
// 排除分片上传的中间操作(init 和 upload),只记录 merge(最终完成上传)
|
||||
if path == "/api/admin/attachments/chunk" {
|
||||
action := ctx.Request().Input("action", "")
|
||||
if action == "" {
|
||||
action = ctx.Request().Query("action", "")
|
||||
}
|
||||
// 只记录 merge 操作,排除 init、upload 和 progress
|
||||
if action != "merge" {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 在请求处理后再获取一次管理员ID(确保JWT中间件已执行)
|
||||
// 如果之前没有获取到,再次尝试从context获取
|
||||
if adminID == 0 {
|
||||
if admin, err := helpers.GetAdminFromContext(ctx); err == nil {
|
||||
adminID = admin.ID
|
||||
}
|
||||
}
|
||||
|
||||
// 默认状态为成功
|
||||
status := uint8(1)
|
||||
var errorMsg string
|
||||
|
||||
// 在goroutine之前保存所有需要的数据,避免context问题
|
||||
savedAdminID := adminID
|
||||
savedMethod := method
|
||||
savedPath := path
|
||||
savedIP := ip
|
||||
savedUserAgent := userAgent
|
||||
savedRequestBody := requestBody
|
||||
savedDuration := duration
|
||||
|
||||
// 提前获取 traceCtx,用于日志记录
|
||||
traceCtx := traceid.DeriveContextFromHTTP(ctx)
|
||||
|
||||
// 生成操作标题(只使用权限标识)
|
||||
title := utils.GetOperationTitleFromContext(ctx)
|
||||
if title == "operation.unknown" {
|
||||
// 如果无法生成标题,记录调试日志
|
||||
logger.ErrorfContext(traceCtx, "Failed to generate operation title, method: %s, path: %s", savedMethod, savedPath)
|
||||
}
|
||||
|
||||
operationLog := models.OperationLog{
|
||||
AdminID: savedAdminID,
|
||||
Method: savedMethod,
|
||||
Path: savedPath,
|
||||
Title: title,
|
||||
IP: savedIP,
|
||||
UserAgent: savedUserAgent,
|
||||
Request: savedRequestBody,
|
||||
Status: status,
|
||||
ErrorMsg: errorMsg,
|
||||
Duration: savedDuration,
|
||||
}
|
||||
|
||||
// 异步记录日志,避免影响响应速度
|
||||
go func(ctx context.Context) {
|
||||
if err := facades.Orm().Query().Create(&operationLog); err != nil {
|
||||
_ = systemLogService.Record(ctx, "error", "operation-log", "failed to persist operation log", map[string]any{
|
||||
"error": err.Error(),
|
||||
"path": savedPath,
|
||||
})
|
||||
logger.ErrorfContext(ctx, "Failed to create operation log: %v", err)
|
||||
}
|
||||
}(traceCtx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
|
||||
"goravel/app/http/trans"
|
||||
"goravel/app/models"
|
||||
"goravel/app/services"
|
||||
"goravel/app/utils/errorlog"
|
||||
"goravel/app/utils/logger"
|
||||
)
|
||||
|
||||
// Permission 权限验证中间件
|
||||
func Permission() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
// 从context中获取admin信息(由JWT中间件设置)
|
||||
adminValue := ctx.Value("admin")
|
||||
if adminValue == nil {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": 401,
|
||||
"message": trans.Get(ctx, "not_logged_in"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
admin, ok := adminValue.(models.Admin)
|
||||
if !ok {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": 401,
|
||||
"message": trans.Get(ctx, "not_logged_in"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 加载管理员的角色、权限等关联数据
|
||||
adminService := services.NewAdminServiceImpl()
|
||||
if err := adminService.LoadRelationsWithPermissions(&admin); err != nil {
|
||||
logger.ErrorfHTTP(ctx, "permission middleware load relations failed: %v", err)
|
||||
errorlog.RecordHTTP(ctx, "permission", "Failed to load admin relations with permissions", map[string]any{
|
||||
"error": err.Error(),
|
||||
"admin_id": admin.ID,
|
||||
"path": ctx.Request().Path(),
|
||||
}, "Load admin relations failed: %v", err)
|
||||
_ = ctx.Response().Json(http.StatusInternalServerError, http.Json{
|
||||
"code": 500,
|
||||
"message": trans.Get(ctx, "load_permissions_failed"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是超级管理员
|
||||
// 拥有 super-admin 角色的管理员(包括超级管理员和开发者管理员)都跳过权限“拦截”,但仍然参与权限匹配,用于生成操作标题
|
||||
isSuperAdmin := false
|
||||
for _, role := range admin.Roles {
|
||||
if role.Slug == "super-admin" && role.Status == 1 {
|
||||
isSuperAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前请求的方法和路径
|
||||
method := ctx.Request().Method()
|
||||
path := ctx.Request().Path()
|
||||
|
||||
// 收集所有角色的权限(已通过预加载获取)
|
||||
var allPermissions []models.Permission
|
||||
for _, role := range admin.Roles {
|
||||
if role.Status == 1 {
|
||||
allPermissions = append(allPermissions, role.Permissions...)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有权限,并记录匹配的权限标识
|
||||
hasPermission := false
|
||||
var matchedPermissionSlug string
|
||||
var menuDisabled bool
|
||||
for _, perm := range allPermissions {
|
||||
if perm.Status == 1 {
|
||||
// 检查方法匹配
|
||||
if perm.Method == "" || perm.Method == method {
|
||||
// 检查路径匹配(支持通配符)
|
||||
if perm.Path == "" || perm.Path == path || matchPath(perm.Path, path) {
|
||||
// 检查关联菜单的状态(如果权限关联了菜单)
|
||||
// 如果权限没有关联菜单(MenuID = 0),则允许访问
|
||||
// 如果权限关联了菜单,需要检查菜单状态是否为启用(status = 1)
|
||||
if perm.MenuID == 0 {
|
||||
// 权限没有关联菜单,允许访问
|
||||
hasPermission = true
|
||||
matchedPermissionSlug = perm.Slug
|
||||
break
|
||||
} else if perm.Menu.ID > 0 {
|
||||
// 权限关联了菜单,检查菜单状态
|
||||
if perm.Menu.Status == 1 {
|
||||
// 菜单状态为启用,允许访问
|
||||
hasPermission = true
|
||||
matchedPermissionSlug = perm.Slug
|
||||
break
|
||||
} else {
|
||||
// 菜单状态为关闭,记录但继续查找其他权限
|
||||
menuDisabled = true
|
||||
}
|
||||
}
|
||||
// 如果菜单没有加载(perm.Menu.ID == 0),为了安全起见,不允许访问
|
||||
// 这种情况应该很少发生,因为我们已经预加载了菜单
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非超级管理员且无匹配权限时拦截;超级管理员即使无匹配权限也放行
|
||||
if !hasPermission && !isSuperAdmin {
|
||||
// 如果是因为菜单状态为关闭而禁止访问,返回更具体的错误信息
|
||||
if menuDisabled {
|
||||
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
|
||||
"code": 403,
|
||||
"message": trans.Get(ctx, "menu_disabled"),
|
||||
}).Abort()
|
||||
} else {
|
||||
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
|
||||
"code": 403,
|
||||
"message": trans.Get(ctx, "no_permission"),
|
||||
}).Abort()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 将匹配的权限标识存储到 context 中,供操作日志使用
|
||||
if matchedPermissionSlug != "" {
|
||||
ctx.WithValue("permission_slug", matchedPermissionSlug)
|
||||
}
|
||||
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
|
||||
// matchPath 路径匹配,支持通配符
|
||||
// 支持的模式:
|
||||
// 1. 精确匹配:/api/admin/roles 匹配 /api/admin/roles
|
||||
// 2. 末尾通配符:/api/admin/roles/* 匹配 /api/admin/roles/1
|
||||
// 3. 中间通配符:/api/admin/attachments/*/display-name 匹配 /api/admin/attachments/1/display-name
|
||||
func matchPath(pattern, path string) bool {
|
||||
if pattern == path {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果模式不包含通配符,直接返回 false
|
||||
if !contains(pattern, '*') {
|
||||
return false
|
||||
}
|
||||
|
||||
// 将模式按 * 分割成多个部分
|
||||
parts := splitPattern(pattern)
|
||||
if len(parts) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模式以 * 开头,需要特殊处理
|
||||
if pattern[0] == '*' {
|
||||
// 检查路径是否以模式的剩余部分结尾
|
||||
if len(parts) > 1 {
|
||||
suffix := parts[1]
|
||||
return len(path) >= len(suffix) && path[len(path)-len(suffix):] == suffix
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果模式以 * 结尾
|
||||
if pattern[len(pattern)-1] == '*' {
|
||||
prefix := pattern[:len(pattern)-1]
|
||||
if len(path) >= len(prefix) {
|
||||
pathPrefix := path[:len(prefix)]
|
||||
if pathPrefix == prefix {
|
||||
// 如果前缀以 / 结尾,路径必须比前缀长(即后面还有内容)
|
||||
if len(prefix) > 0 && prefix[len(prefix)-1] == '/' {
|
||||
return len(path) > len(prefix)
|
||||
}
|
||||
// 如果前缀不以 / 结尾,路径可以等于或长于前缀
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 处理中间有通配符的情况,如 /api/admin/attachments/*/display-name
|
||||
// 将模式按 * 分割
|
||||
patternParts := splitPattern(pattern)
|
||||
if len(patternParts) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 过滤掉 "*" 标记,只保留实际的部分
|
||||
var actualParts []string
|
||||
for _, part := range patternParts {
|
||||
if part != "*" {
|
||||
actualParts = append(actualParts, part)
|
||||
}
|
||||
}
|
||||
|
||||
if len(actualParts) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查路径是否以第一部分开头
|
||||
firstPart := actualParts[0]
|
||||
if len(path) < len(firstPart) || path[:len(firstPart)] != firstPart {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查路径是否以最后一部分结尾
|
||||
lastPart := actualParts[len(actualParts)-1]
|
||||
if len(path) < len(lastPart) || path[len(path)-len(lastPart):] != lastPart {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查中间部分是否存在(通配符匹配任意内容)
|
||||
// 路径应该是:firstPart + 任意内容 + lastPart
|
||||
remainingPath := path[len(firstPart) : len(path)-len(lastPart)]
|
||||
// 确保中间部分不为空(至少有一个字符,通常是数字ID)
|
||||
return len(remainingPath) > 0
|
||||
}
|
||||
|
||||
// contains 检查字符串是否包含指定字符
|
||||
func contains(s string, c byte) bool {
|
||||
for i := range s {
|
||||
if s[i] == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitPattern 按 * 分割模式字符串
|
||||
func splitPattern(pattern string) []string {
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
|
||||
for i := range pattern {
|
||||
if pattern[i] == '*' {
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
parts = append(parts, "*")
|
||||
} else {
|
||||
current.WriteByte(pattern[i])
|
||||
}
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
|
||||
"goravel/app/http/helpers"
|
||||
)
|
||||
|
||||
// TimezoneQuery 时区查询参数转换中间件
|
||||
// 自动将查询参数中的时间字段从本地时区转换为 UTC 时间用于数据库查询
|
||||
func TimezoneQuery() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
// 定义需要转换的时间查询参数名称
|
||||
timeParams := []string{
|
||||
"start_time",
|
||||
"end_time",
|
||||
"created_at_start",
|
||||
"created_at_end",
|
||||
"updated_at_start",
|
||||
"updated_at_end",
|
||||
"deleted_at_start",
|
||||
"deleted_at_end",
|
||||
}
|
||||
|
||||
// 转换每个时间参数
|
||||
for _, paramName := range timeParams {
|
||||
timeStr := ctx.Request().Query(paramName, "")
|
||||
if timeStr != "" {
|
||||
// 转换为 UTC 时间并存储在 context 中
|
||||
utcTime := helpers.ConvertTimeToUTC(ctx, timeStr)
|
||||
// 将转换后的时间存储在 context 中,供控制器使用
|
||||
ctx.WithValue("timezone_query_"+paramName, utcTime)
|
||||
}
|
||||
}
|
||||
|
||||
// 继续处理请求
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
|
||||
"goravel/app/utils/traceid"
|
||||
)
|
||||
|
||||
// Trace middleware ensures every request carries a trace id and mirrors it in response headers.
|
||||
func Trace() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
traceID := traceid.EnsureHTTPContext(ctx, "")
|
||||
ctx.Response().Header(traceid.HeaderName(), traceID)
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
"github.com/goravel/framework/support/str"
|
||||
|
||||
"goravel/app/http/trans"
|
||||
"goravel/app/models"
|
||||
)
|
||||
|
||||
// UserJwt C端用户JWT认证中间件(使用Goravel标准Auth)
|
||||
func UserJwt() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
// 如果路径是api/user前缀,使用user guard
|
||||
path := ctx.Request().Path()
|
||||
pathStr := str.Of(path)
|
||||
if pathStr.IsEmpty() || !pathStr.StartsWith("/api/user") {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 使用Goravel标准Auth解析token
|
||||
if _, err := facades.Auth(ctx).Guard("user").Parse(ctx.Request().Header("Authorization", "")); err != nil {
|
||||
// 如果Header中没有token,尝试从URL参数中获取
|
||||
if token := ctx.Request().Query("_token", ""); token != "" {
|
||||
if _, err := facades.Auth(ctx).Guard("user").Parse(token); err != nil {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "invalid_token"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "not_logged_in"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
var user models.User
|
||||
if err := facades.Auth(ctx).Guard("user").User(&user); err != nil {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "user_not_found"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if user.ID == 0 {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "user_not_found"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if user.Status == 0 {
|
||||
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
|
||||
"code": http.StatusForbidden,
|
||||
"message": trans.Get(ctx, "account_disabled"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存储到context中,供后续中间件使用
|
||||
ctx.WithValue("user", user)
|
||||
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user