feat: rbac迁移完成,并已部署至dev服务器
This commit is contained in:
@@ -21,16 +21,33 @@ Cors:
|
||||
- Content-Type
|
||||
- Authorization
|
||||
- X-Requested-With
|
||||
- X-Client-Id
|
||||
|
||||
# system-rpc 连接(用于写入操作日志)
|
||||
SystemRpc:
|
||||
Etcd:
|
||||
Hosts:
|
||||
- 192.168.100.127:2379
|
||||
Key: system.rpc
|
||||
|
||||
# JWT 密钥(与 user-api 的 Auth.AccessSecret 一致,用于解析 token 获取 userId)
|
||||
JwtSecret: "9149f2eb-d517-4a50-a03a-231dbcf0d872"
|
||||
|
||||
# 鉴权白名单(无需 Token 的路径,精确匹配或 /* 前缀通配)
|
||||
AuthWhitelist:
|
||||
- /api/auth/login
|
||||
- /api/auth/loginByPhone
|
||||
- /api/auth/miniLogin
|
||||
- /api/auth/captcha
|
||||
- /api/plant/callback/wechatpay
|
||||
|
||||
# 上游服务路由表
|
||||
Upstreams:
|
||||
- Prefix: /api/user
|
||||
Target: http://127.0.0.1:9001
|
||||
- Prefix: /api/auth
|
||||
Target: http://192.168.100.2:9001
|
||||
- Prefix: /api/file
|
||||
Target: http://127.0.0.1:9002
|
||||
Target: http://192.168.100.2:9002
|
||||
- Prefix: /api/sys
|
||||
Target: http://127.0.0.1:9003
|
||||
Target: http://192.168.100.2:9003
|
||||
- Prefix: /api/plant
|
||||
Target: http://127.0.0.1:9004
|
||||
- Prefix: /api/radio
|
||||
Target: http://127.0.0.1:9005
|
||||
Target: http://192.168.100.2:9004
|
||||
|
||||
+22
-1
@@ -8,23 +8,34 @@ import (
|
||||
"sundynix-micro-go/app/gateway/internal/config"
|
||||
"sundynix-micro-go/app/gateway/internal/handler"
|
||||
"sundynix-micro-go/app/gateway/internal/middleware"
|
||||
"sundynix-micro-go/app/system/rpc/systemservice"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/conf"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
"github.com/zeromicro/go-zero/core/stat"
|
||||
"github.com/zeromicro/go-zero/zrpc"
|
||||
)
|
||||
|
||||
var configFile = flag.String("f", "etc/gateway.yaml", "the config file")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
stat.DisableLog()
|
||||
|
||||
var c config.Config
|
||||
conf.MustLoad(*configFile, &c)
|
||||
|
||||
// 手动初始化日志(自定义 gateway 没用 rest.MustNewServer,需要显式设置)
|
||||
logx.MustSetup(c.Log)
|
||||
|
||||
// 初始化 system-rpc 客户端(用于写操作日志)
|
||||
systemRpc := systemservice.NewSystemService(zrpc.MustNewClient(c.SystemRpc))
|
||||
|
||||
// 构建反向代理路由器
|
||||
proxyRouter := handler.NewProxyRouter(c.Upstreams)
|
||||
|
||||
// 构建请求处理链
|
||||
// 构建中间件链(执行顺序:操作日志 → 鉴权 → CORS → 代理)
|
||||
// 注意:由于是外层包内层,实际请求流程是:OpLog → Auth → CORS → Proxy → CORS → Auth → OpLog
|
||||
var finalHandler http.Handler = proxyRouter
|
||||
|
||||
// 注入 CORS 中间件
|
||||
@@ -35,6 +46,14 @@ func main() {
|
||||
}))
|
||||
}
|
||||
|
||||
// 注入 JWT 鉴权中间件(含滑动窗口续期)
|
||||
authMiddleware := middleware.NewAuthMiddleware(c.JwtSecret, c.AuthWhitelist)
|
||||
finalHandler = authMiddleware.Handle(finalHandler)
|
||||
|
||||
// 注入操作日志中间件(在最外层,记录完整的请求-响应周期,含鉴权状态)
|
||||
opLogMiddleware := middleware.NewOperationLogMiddleware(systemRpc, c.JwtSecret)
|
||||
finalHandler = opLogMiddleware.Handle(finalHandler)
|
||||
|
||||
// 健康检查
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -50,6 +69,8 @@ func main() {
|
||||
for _, u := range c.Upstreams {
|
||||
logx.Infof(" %s -> %s", u.Prefix, u.Target)
|
||||
}
|
||||
logx.Infof("中间件: CORS | JWT鉴权(滑动续期) | 操作日志→system-rpc")
|
||||
logx.Infof("鉴权白名单: %d 条", len(c.AuthWhitelist))
|
||||
logx.Infof("================================")
|
||||
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package config
|
||||
|
||||
import "github.com/zeromicro/go-zero/rest"
|
||||
import (
|
||||
"github.com/zeromicro/go-zero/rest"
|
||||
"github.com/zeromicro/go-zero/zrpc"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
rest.RestConf
|
||||
@@ -13,6 +16,15 @@ type Config struct {
|
||||
}
|
||||
|
||||
Upstreams []Upstream
|
||||
|
||||
// system-rpc 连接(用于写入操作日志)
|
||||
SystemRpc zrpc.RpcClientConf
|
||||
|
||||
// JWT 密钥(用于解析 Token 获取 userId,与 user-api 的 Auth.AccessSecret 保持一致)
|
||||
JwtSecret string `json:",optional"`
|
||||
|
||||
// 无需鉴权的路径白名单
|
||||
AuthWhitelist []string `json:",optional"`
|
||||
}
|
||||
|
||||
type Upstream struct {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -49,19 +51,38 @@ func NewProxyRouter(upstreams []config.Upstream) *ProxyRouter {
|
||||
|
||||
// 自定义 Transport:超时控制
|
||||
proxy.Transport = &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second, // 上游响应头超时,超出则触发 ErrorHandler
|
||||
}
|
||||
|
||||
// 错误处理
|
||||
prefix := u.Prefix
|
||||
targetAddr := u.Target
|
||||
|
||||
// ErrorHandler:处理网络层错误(连接失败、超时等)
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
logx.Errorf("代理请求失败 [%s%s -> %s]: %v", prefix, r.URL.Path, targetAddr, err)
|
||||
logx.Errorf("[Gateway] ❌ 上游连接失败 | %s %s -> %s | 错误: %v",
|
||||
r.Method, r.URL.Path, targetAddr, err)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
fmt.Fprintf(w, `{"code":502,"msg":"上游服务不可用: %s"}`, prefix)
|
||||
fmt.Fprintf(w, `{"code":502,"msg":"上游服务不可用,请检查 %s 是否正常运行"}`, prefix)
|
||||
}
|
||||
|
||||
// ModifyResponse:捕获上游返回的 4xx/5xx 并记录日志(网络层正常但业务异常)
|
||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
||||
if resp.StatusCode >= 500 {
|
||||
// 读取响应体用于日志(最多 1KB,读完后要写回)
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
resp.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
logx.Errorf("[Gateway] ⚠️ 上游服务异常 | %s %s -> %s | 状态码: %d | 响应: %s",
|
||||
resp.Request.Method, resp.Request.URL.Path, targetAddr,
|
||||
resp.StatusCode, string(body))
|
||||
} else if resp.StatusCode >= 400 {
|
||||
logx.Infof("[Gateway] ℹ️ 上游返回客户端错误 | %s %s | 状态码: %d",
|
||||
resp.Request.Method, resp.Request.URL.Path, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
router.routes = append(router.routes, &route{
|
||||
@@ -82,13 +103,14 @@ func (pr *ProxyRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
for _, rt := range pr.routes {
|
||||
if strings.HasPrefix(path, rt.prefix) {
|
||||
logx.Infof("[Gateway] %s %s -> %s", r.Method, path, rt.target)
|
||||
logx.Infof("[Gateway] → %s %s -> %s", r.Method, path, rt.target)
|
||||
rt.proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 没有匹配的路由
|
||||
logx.Errorf("[Gateway] ❌ 路由未找到: %s %s", r.Method, path)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `{"code":404,"msg":"路由未找到: %s"}`, path)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwtUtil "sundynix-micro-go/common/utils/jwt"
|
||||
|
||||
jwtv5 "github.com/golang-jwt/jwt/v5"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
// RefreshTokenHeader 续期后新 Token 放在此响应头里,前端读取后静默替换
|
||||
const RefreshTokenHeader = "X-Refresh-Token"
|
||||
|
||||
// AuthMiddleware 网关鉴权 + 自动续期中间件
|
||||
type AuthMiddleware struct {
|
||||
jwtSecret string
|
||||
whitelist map[string]bool
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(jwtSecret string, whitelist []string) *AuthMiddleware {
|
||||
wl := make(map[string]bool, len(whitelist))
|
||||
for _, p := range whitelist {
|
||||
wl[p] = true
|
||||
}
|
||||
return &AuthMiddleware{
|
||||
jwtSecret: jwtSecret,
|
||||
whitelist: wl,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 适配 http.Handler 链(自定义 gateway 使用 http.Handler 链式调用)
|
||||
func (m *AuthMiddleware) Handle(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// OPTIONS 预检直接放行
|
||||
if r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 白名单路径放行(支持精确匹配和 /* 前缀通配)
|
||||
if m.isWhitelisted(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 Authorization 头
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
writeUnauthorized(w, "缺少 Authorization 请求头")
|
||||
return
|
||||
}
|
||||
tokenStr := jwtUtil.GetTokenFromHeader(authHeader)
|
||||
if tokenStr == "" {
|
||||
writeUnauthorized(w, "Token 格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
j := jwtUtil.NewJWT(m.jwtSecret)
|
||||
claims, err := j.ParseToken(tokenStr)
|
||||
if err != nil {
|
||||
logx.Infof("[gateway] JWT 解析失败: %v, path: %s", err, r.URL.Path)
|
||||
writeUnauthorized(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息透传到上游,避免上游重复解析 JWT
|
||||
r.Header.Set("X-User-Id", claims.BaseClaims.ID)
|
||||
r.Header.Set("X-User-Account", claims.BaseClaims.Account)
|
||||
|
||||
// ---- 滑动窗口续期 ----
|
||||
if newToken, ok := m.tryRefresh(j, claims); ok {
|
||||
w.Header().Set(RefreshTokenHeader, newToken)
|
||||
logx.Infof("[gateway] Token 已续期, userId: %s", claims.BaseClaims.ID)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// tryRefresh 判断是否需要续期,需要则签发新 Token 并返回
|
||||
// 续期规则:剩余有效时间 < BufferTime → 以原始有效时长(ExpiresAt - NotBefore)重新签发
|
||||
func (m *AuthMiddleware) tryRefresh(j *jwtUtil.JWT, claims *jwtUtil.CustomClaims) (string, bool) {
|
||||
bufferTime := time.Duration(claims.BufferTime) * time.Second
|
||||
expiresAt := claims.RegisteredClaims.ExpiresAt.Time
|
||||
remaining := time.Until(expiresAt)
|
||||
|
||||
// 未进入缓冲窗口,无需续期
|
||||
if remaining >= bufferTime {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 计算原始有效时长:ExpiresAt - NotBefore ≈ 当初登录时配置的 activeTimeout
|
||||
notBefore := claims.RegisteredClaims.NotBefore.Time
|
||||
originalDuration := expiresAt.Sub(notBefore)
|
||||
|
||||
newClaims := jwtUtil.CustomClaims{
|
||||
BaseClaims: claims.BaseClaims,
|
||||
BufferTime: claims.BufferTime,
|
||||
RegisteredClaims: jwtv5.RegisteredClaims{
|
||||
Audience: claims.RegisteredClaims.Audience,
|
||||
Issuer: claims.RegisteredClaims.Issuer,
|
||||
NotBefore: jwtv5.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwtv5.NewNumericDate(time.Now().Add(originalDuration)),
|
||||
},
|
||||
}
|
||||
|
||||
newToken, err := j.CreateToken(newClaims)
|
||||
if err != nil {
|
||||
logx.Errorf("[gateway] Token 续期失败: %v", err)
|
||||
return "", false
|
||||
}
|
||||
return newToken, true
|
||||
}
|
||||
|
||||
// isWhitelisted 支持精确匹配和 /* 前缀通配
|
||||
func (m *AuthMiddleware) isWhitelisted(path string) bool {
|
||||
if m.whitelist[path] {
|
||||
return true
|
||||
}
|
||||
for p := range m.whitelist {
|
||||
if strings.HasSuffix(p, "/*") && strings.HasPrefix(path, strings.TrimSuffix(p, "*")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// writeUnauthorized 返回统一的 401 响应
|
||||
func writeUnauthorized(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 401,
|
||||
"msg": msg,
|
||||
})
|
||||
}
|
||||
@@ -42,6 +42,8 @@ func (m *CorsMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
|
||||
w.Header().Set("Access-Control-Allow-Headers", strings.Join(m.allowHeaders, ", "))
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Max-Age", "3600")
|
||||
// 允许前端 JS 读取自定义响应头(用于 Token 自动续期)
|
||||
w.Header().Set("Access-Control-Expose-Headers", "X-Refresh-Token")
|
||||
}
|
||||
|
||||
// 预检请求直接返回
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sundynix-micro-go/app/system/rpc/system"
|
||||
"sundynix-micro-go/app/system/rpc/systemservice"
|
||||
jwtUtil "sundynix-micro-go/common/utils/jwt"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
// OperationLogMiddleware 操作日志中间件
|
||||
type OperationLogMiddleware struct {
|
||||
systemRpc systemservice.SystemService
|
||||
jwtSecret string
|
||||
logChan chan *system.CreateOperationRecordReq
|
||||
}
|
||||
|
||||
// NewOperationLogMiddleware 创建操作日志中间件
|
||||
func NewOperationLogMiddleware(systemRpc systemservice.SystemService, jwtSecret string) *OperationLogMiddleware {
|
||||
m := &OperationLogMiddleware{
|
||||
systemRpc: systemRpc,
|
||||
jwtSecret: jwtSecret,
|
||||
logChan: make(chan *system.CreateOperationRecordReq, 500),
|
||||
}
|
||||
// 启动异步消费者,避免阻塞请求
|
||||
go m.consumer()
|
||||
return m
|
||||
}
|
||||
|
||||
// Handle 中间件处理函数
|
||||
func (m *OperationLogMiddleware) Handle(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 跳过健康检查、OPTIONS 预检
|
||||
if r.URL.Path == "/health" || r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// 读取请求体(限制大小,跳过文件上传)
|
||||
var bodyStr string
|
||||
if r.Body != nil && !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(r.Body, 2048))
|
||||
bodyStr = string(bodyBytes)
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
}
|
||||
|
||||
clientId := r.Header.Get("X-Client-Id")
|
||||
userId := m.extractUserId(r)
|
||||
clientIP := getClientIP(r)
|
||||
|
||||
// 包装 ResponseWriter 捕获响应
|
||||
rw := &responseCapture{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
// 执行后续 handler
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
latency := time.Since(startTime)
|
||||
|
||||
// 构建日志请求
|
||||
record := &system.CreateOperationRecordReq{
|
||||
ClientId: clientId,
|
||||
Ip: clientIP,
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Status: int32(rw.statusCode),
|
||||
Latency: latency.Nanoseconds(),
|
||||
Agent: truncate(r.UserAgent(), 500),
|
||||
ErrorMessage: rw.errorMsg(),
|
||||
Body: truncate(bodyStr, 2000),
|
||||
Resp: truncate(rw.body.String(), 2000),
|
||||
UserId: userId,
|
||||
}
|
||||
|
||||
// 异步发送,不阻塞响应
|
||||
select {
|
||||
case m.logChan <- record:
|
||||
default:
|
||||
logx.Error("操作日志缓冲区满,丢弃日志")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// consumer 异步消费日志并通过 system-rpc 写入
|
||||
func (m *OperationLogMiddleware) consumer() {
|
||||
for record := range m.logChan {
|
||||
_, err := m.systemRpc.CreateOperationRecord(context.Background(), record)
|
||||
if err != nil {
|
||||
logx.Errorf("写入操作日志失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractUserId 从请求中获取 userId
|
||||
// 优先从鉴权中间件注入的 X-User-Id 头获取(避免重复解析 JWT)
|
||||
func (m *OperationLogMiddleware) extractUserId(r *http.Request) string {
|
||||
if uid := r.Header.Get("X-User-Id"); uid != "" {
|
||||
return uid
|
||||
}
|
||||
// fallback: 自己解析 JWT
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return ""
|
||||
}
|
||||
tokenStr := jwtUtil.GetTokenFromHeader(authHeader)
|
||||
if tokenStr == "" {
|
||||
return ""
|
||||
}
|
||||
j := jwtUtil.NewJWT(m.jwtSecret)
|
||||
claims, err := j.ParseToken(tokenStr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return claims.BaseClaims.ID
|
||||
}
|
||||
|
||||
// responseCapture 捕获响应状态码和响应体
|
||||
type responseCapture struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body bytes.Buffer
|
||||
}
|
||||
|
||||
func (rc *responseCapture) WriteHeader(code int) {
|
||||
rc.statusCode = code
|
||||
rc.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rc *responseCapture) Write(b []byte) (int, error) {
|
||||
if rc.body.Len() < 2048 {
|
||||
rc.body.Write(b)
|
||||
}
|
||||
return rc.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (rc *responseCapture) errorMsg() string {
|
||||
if rc.statusCode >= 400 {
|
||||
return rc.body.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getClientIP 获取真实客户端 IP
|
||||
func getClientIP(r *http.Request) string {
|
||||
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||
return strings.Split(ip, ",")[0]
|
||||
}
|
||||
if ip := r.Header.Get("X-Real-Ip"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
addr := r.RemoteAddr
|
||||
if idx := strings.LastIndex(addr, ":"); idx != -1 {
|
||||
return addr[:idx]
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// truncate 截断字符串
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user