feat: add unified authorization middleware

This commit is contained in:
2026-08-12 11:46:48 +08:00
parent 2d1deb54f0
commit fa6898aef8
31 changed files with 341 additions and 490 deletions

View File

@@ -4,6 +4,7 @@ import (
"net"
"os"
"strings"
"time"
ftsService "bsm/full/module/base/fts/service"
mgtService "bsm/full/module/base/mgt/service"
@@ -11,24 +12,26 @@ import (
senderService "bsm/full/module/base/sender/service"
walletService "bsm/full/module/finance/wallet/service"
"git.apinb.com/bsm-sdk/core/conf"
"git.apinb.com/bsm-sdk/core/env"
"git.apinb.com/bsm-sdk/core/printer"
coreVars "git.apinb.com/bsm-sdk/core/vars"
)
type SrvConfig struct {
conf.Base `yaml:",inline"`
Server ServerConfig `yaml:"Server"`
DynamicRPC DynamicRPCConfig `yaml:"DynamicRPC"`
Databases *conf.DBConf `yaml:"Databases"`
MicroService *conf.MicroServiceConf `yaml:"MicroService"`
Rpc map[string]conf.RpcConf `yaml:"Rpc"`
Apm *conf.ApmConf `yaml:"APM"`
Etcd *conf.EtcdConf `yaml:"Etcd"`
Services []string `yaml:"Services"`
Fts *ftsService.Config `yaml:"Fts"`
Mgt *mgtService.Config `yaml:"Mgt"`
Passport *passportService.Config `yaml:"Passport"`
Sender *senderService.Config `yaml:"Sender"`
Wallet *walletService.Config `yaml:"Wallet"`
conf.Base `yaml:",inline"`
Server ServerConfig `yaml:"Server"`
Authorization AuthorizationConfig `yaml:"Authorization"`
Databases *conf.DBConf `yaml:"Databases"`
MicroService *conf.MicroServiceConf `yaml:"MicroService"`
Rpc map[string]conf.RpcConf `yaml:"Rpc"`
Apm *conf.ApmConf `yaml:"APM"`
Etcd *conf.EtcdConf `yaml:"Etcd"`
Services []string `yaml:"Services"`
Fts *ftsService.Config `yaml:"Fts"`
Mgt *mgtService.Config `yaml:"Mgt"`
Passport *passportService.Config `yaml:"Passport"`
Sender *senderService.Config `yaml:"Sender"`
Wallet *walletService.Config `yaml:"Wallet"`
}
type ListenerConfig struct {
@@ -42,8 +45,10 @@ type ServerConfig struct {
HTTP ListenerConfig `yaml:"HTTP"`
}
type DynamicRPCConfig struct {
Allow []string `yaml:"Allow"`
type AuthorizationConfig struct {
Key string `yaml:"Key"`
Expire int64 `yaml:"Expire"`
Anonymous []string `yaml:"Anonymous"`
}
var Spec SrvConfig
@@ -55,6 +60,18 @@ func New(serviceKey string) {
if Spec.Server.GRPC.Addr == Spec.Server.HTTP.Addr {
panic("gRPC and HTTP listeners must use different addresses")
}
if strings.TrimSpace(Spec.Authorization.Key) == "" {
panic("Authorization.Key must not be empty")
}
keyLength := len(Spec.Authorization.Key)
if keyLength != 16 && keyLength != 24 && keyLength != 32 {
panic("Authorization.Key must contain 16, 24, or 32 bytes")
}
if Spec.Authorization.Expire <= 0 {
panic("Authorization.Expire must be greater than zero")
}
env.NewEnv().JwtSecretKey = Spec.Authorization.Key
coreVars.JwtExpire = time.Duration(Spec.Authorization.Expire) * time.Second
// Keep the embedded base address meaningful for module configurations that
// still consume it, while all itself uses the two explicit listeners.
Spec.BindIP, Spec.Port, Spec.Addr = Spec.Server.HTTP.BindIP, Spec.Server.HTTP.Port, Spec.Server.HTTP.Addr

View File

@@ -23,8 +23,12 @@ func TestAllDevConfig(t *testing.T) {
if cfg.Server.GRPC.Port == "" || cfg.Server.HTTP.Port == "" || cfg.Server.GRPC.Port == cfg.Server.HTTP.Port {
t.Fatal("separate gRPC and HTTP ports are required")
}
if len(cfg.DynamicRPC.Allow) == 0 {
t.Fatal("dynamic RPC allow list must be explicit")
if cfg.Authorization.Key == "" || cfg.Authorization.Expire <= 0 {
t.Fatal("authorization key and expiration are required")
}
keyLength := len(cfg.Authorization.Key)
if keyLength != 16 && keyLength != 24 && keyLength != 32 {
t.Fatal("authorization key must be compatible with the JWT issuer")
}
if cfg.Fts == nil || cfg.Mgt == nil || cfg.Passport == nil || cfg.Sender == nil || cfg.Wallet == nil {
t.Fatal("service-specific configuration is incomplete")

View File

@@ -0,0 +1,123 @@
package server
import (
"context"
"errors"
"fmt"
"net/http"
"path"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
type authorization struct {
key []byte
expire time.Duration
anonymous map[string]struct{}
}
func newAuthorization(key string, expireSeconds int64, anonymous []string) (*authorization, error) {
key = strings.TrimSpace(key)
if key == "" {
return nil, errors.New("authorization key must not be empty")
}
if expireSeconds <= 0 {
return nil, errors.New("authorization expiration must be greater than zero")
}
allowed := make(map[string]struct{}, len(anonymous))
for _, item := range anonymous {
if item = normalizePath(item); item != "" {
allowed[item] = struct{}{}
}
}
return &authorization{key: []byte(key), expire: time.Duration(expireSeconds) * time.Second, anonymous: allowed}, nil
}
func (a *authorization) unaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if !a.isAnonymous(info.FullMethod) {
values := metadata.ValueFromIncomingContext(ctx, "authorization")
if len(values) == 0 {
return nil, status.Error(codes.Unauthenticated, "authorization is required")
}
if err := a.validate(values[0]); err != nil {
return nil, status.Error(codes.Unauthenticated, err.Error())
}
}
return handler(ctx, req)
}
func (a *authorization) httpMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestPath := canonicalHTTPPath(r.URL.Path)
if !a.isAnonymous(requestPath) {
if err := a.validate(r.Header.Get("Authorization")); err != nil {
writeHTTPAuthorizationError(w, err)
return
}
}
next.ServeHTTP(w, r)
})
}
func (a *authorization) validate(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return errors.New("authorization is required")
}
claims := &jwt.RegisteredClaims{}
tokenValue, err := jwt.ParseWithClaims(raw, claims, func(tokenValue *jwt.Token) (any, error) {
if tokenValue.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method: %s", tokenValue.Method.Alg())
}
return a.key, nil
}, jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil || !tokenValue.Valid {
return errors.New("authorization is invalid or expired")
}
if claims.IssuedAt == nil {
return errors.New("authorization issued-at is required")
}
now := time.Now()
if claims.IssuedAt.Time.After(now) || now.Sub(claims.IssuedAt.Time) > a.expire {
return errors.New("authorization is expired")
}
return nil
}
func (a *authorization) isAnonymous(requestPath string) bool {
requestPath = normalizePath(requestPath)
if strings.HasPrefix(requestPath, "/grpc.reflection.") || strings.HasPrefix(requestPath, "/grpc.health.") {
return true
}
_, ok := a.anonymous[requestPath]
return ok
}
func canonicalHTTPPath(requestPath string) string {
normalized := normalizePath(requestPath)
parts := strings.Split(strings.TrimPrefix(normalized, "/"), "/")
if len(parts) == 4 && parts[0] == "rpc" {
return "/" + parts[1] + "." + parts[2] + "/" + parts[3]
}
return normalized
}
func normalizePath(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return path.Clean("/" + strings.TrimPrefix(value, "/"))
}
func writeHTTPAuthorizationError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, `{"code":%d,"message":%q}`, codes.Unauthenticated, err.Error())
}

View File

@@ -0,0 +1,100 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
const testAuthorizationKey = "0123456789abcdef0123456789abcdef"
func TestHTTPAuthorization(t *testing.T) {
auth, err := newAuthorization(testAuthorizationKey, 3600, []string{"/passport.Login/Pwd", "/rest/fts/ping"})
if err != nil {
t.Fatal(err)
}
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
handler := auth.httpMiddleware(next)
tests := []struct {
name string
path string
token string
want int
}{
{name: "grpc gateway anonymous", path: "/passport.Login/Pwd", want: http.StatusNoContent},
{name: "dynamic rpc anonymous", path: "/rpc/passport/Login/Pwd", want: http.StatusNoContent},
{name: "rest anonymous", path: "/rest/fts/ping", want: http.StatusNoContent},
{name: "missing token", path: "/passport.Account/Get", want: http.StatusOK},
{name: "valid raw token", path: "/passport.Account/Get", token: signedToken(t, time.Now(), time.Now().Add(time.Hour)), want: http.StatusNoContent},
{name: "bearer rejected", path: "/passport.Account/Get", token: "Bearer " + signedToken(t, time.Now(), time.Now().Add(time.Hour)), want: http.StatusOK},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, test.path, nil)
if test.token != "" {
request.Header.Set("Authorization", test.token)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != test.want {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
if test.want == http.StatusOK && !strings.Contains(response.Body.String(), `"code":16`) {
t.Fatalf("unexpected authorization error: %s", response.Body.String())
}
})
}
}
func TestAuthorizationRejectsTokenOlderThanConfiguredLifetime(t *testing.T) {
auth, err := newAuthorization(testAuthorizationKey, 60, nil)
if err != nil {
t.Fatal(err)
}
if err := auth.validate(signedToken(t, time.Now().Add(-2*time.Minute), time.Now().Add(time.Hour))); err == nil {
t.Fatal("expected token older than configured lifetime to be rejected")
}
}
func TestGRPCAuthorization(t *testing.T) {
auth, err := newAuthorization(testAuthorizationKey, 3600, []string{"/passport.Login/Pwd"})
if err != nil {
t.Fatal(err)
}
handler := func(context.Context, any) (any, error) { return "ok", nil }
if _, err := auth.unaryInterceptor(context.Background(), nil, &grpc.UnaryServerInfo{FullMethod: "/passport.Login/Pwd"}, handler); err != nil {
t.Fatalf("anonymous method failed: %v", err)
}
if _, err := auth.unaryInterceptor(context.Background(), nil, &grpc.UnaryServerInfo{FullMethod: "/passport.Account/Get"}, handler); status.Code(err) != codes.Unauthenticated {
t.Fatalf("expected unauthenticated, got %v", err)
}
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", signedToken(t, time.Now(), time.Now().Add(time.Hour))))
if _, err := auth.unaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/passport.Account/Get"}, handler); err != nil {
t.Fatalf("valid token failed: %v", err)
}
}
func signedToken(t *testing.T, issuedAt, expiresAt time.Time) string {
t.Helper()
claims := jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(issuedAt),
NotBefore: jwt.NewNumericDate(issuedAt),
ExpiresAt: jwt.NewNumericDate(expiresAt),
}
value, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testAuthorizationKey))
if err != nil {
t.Fatal(err)
}
return value
}

View File

@@ -30,7 +30,6 @@ const maxDynamicRPCBody = 4 << 20
type dynamicGateway struct {
conn *grpc.ClientConn
allow map[string]struct{}
mu sync.RWMutex
cache map[string]protoreflect.MethodDescriptor
}
@@ -42,18 +41,12 @@ type dynamicRPCResponse struct {
Details []json.RawMessage `json:"details,omitempty"`
}
func newDynamicGateway(grpcAddr string, allow []string) (*dynamicGateway, error) {
func newDynamicGateway(grpcAddr string) (*dynamicGateway, error) {
conn, err := grpc.NewClient(reflectionTarget(grpcAddr), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, fmt.Errorf("create dynamic gRPC client: %w", err)
}
allowed := make(map[string]struct{}, len(allow))
for _, item := range allow {
if item = strings.TrimSpace(item); item != "" {
allowed[item] = struct{}{}
}
}
return &dynamicGateway{conn: conn, allow: allowed, cache: make(map[string]protoreflect.MethodDescriptor)}, nil
return &dynamicGateway{conn: conn, cache: make(map[string]protoreflect.MethodDescriptor)}, nil
}
func reflectionTarget(addr string) string {
@@ -78,12 +71,6 @@ func (g *dynamicGateway) handle(c *gin.Context) {
return
}
serviceName := moduleName + "." + serviceShortName
fullMethod := serviceName + "." + methodName
if !g.isAllowed(serviceName, fullMethod) {
writeDynamicError(c, status.Error(codes.PermissionDenied, "dynamic RPC method is not allowed"))
return
}
descriptor, err := g.resolveMethod(c.Request.Context(), serviceName, methodName)
if err != nil {
writeDynamicError(c, err)
@@ -125,15 +112,6 @@ func (g *dynamicGateway) handle(c *gin.Context) {
c.JSON(http.StatusOK, dynamicRPCResponse{Code: int32(codes.OK), Message: codes.OK.String(), Data: data})
}
func (g *dynamicGateway) isAllowed(serviceName, fullMethod string) bool {
for _, key := range []string{"*", serviceName, fullMethod} {
if _, ok := g.allow[key]; ok {
return true
}
}
return false
}
func (g *dynamicGateway) resolveMethod(ctx context.Context, serviceName, methodName string) (protoreflect.MethodDescriptor, error) {
cacheKey := serviceName + "." + methodName
g.mu.RLock()

View File

@@ -10,7 +10,6 @@ import (
"github.com/gin-gonic/gin"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/metadata"
@@ -33,7 +32,7 @@ func TestDynamicGatewayInvokesUnaryRPC(t *testing.T) {
_ = listener.Close()
})
gateway, err := newDynamicGateway(listener.Addr().String(), []string{"grpc.health.v1.Health.Check"})
gateway, err := newDynamicGateway(listener.Addr().String())
if err != nil {
t.Fatal(err)
}
@@ -61,26 +60,6 @@ func TestDynamicGatewayInvokesUnaryRPC(t *testing.T) {
}
}
func TestDynamicGatewayDeniesMethodsByDefault(t *testing.T) {
gateway := &dynamicGateway{allow: map[string]struct{}{}}
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.POST("/rpc/:module/:service/:method", gateway.handle)
request := httptest.NewRequest(http.MethodPost, "/rpc/grpc.health.v1/Health/Check", strings.NewReader(`{}`))
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("dynamic RPC errors must use HTTP 200, got %d", response.Code)
}
var payload dynamicRPCResponse
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.Code != int32(codes.PermissionDenied) {
t.Fatalf("expected permission denied, got %s", response.Body.String())
}
}
func TestOutgoingMetadataFiltersHeaders(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/", nil)
request.Header.Set("Authorization", "Bearer token")

View File

@@ -21,10 +21,15 @@ type Server struct {
HTTP *gin.Engine
http *http.Server
dynamic *dynamicGateway
auth *authorization
}
func New() *Server {
grpcServer := grpc.NewServer()
func New(key string, expireSeconds int64, anonymous []string) (*Server, error) {
auth, err := newAuthorization(key, expireSeconds, anonymous)
if err != nil {
return nil, err
}
grpcServer := grpc.NewServer(grpc.UnaryInterceptor(auth.unaryInterceptor))
reflection.Register(grpcServer)
engine := gin.New()
engine.Use(gin.Logger(), gin.Recovery())
@@ -32,10 +37,11 @@ func New() *Server {
GRPC: grpcServer,
Gateway: gwRuntime.NewServeMux(),
HTTP: engine,
}
auth: auth,
}, nil
}
func (s *Server) Start(grpcAddr, httpAddr string, allow []string) error {
func (s *Server) Start(grpcAddr, httpAddr string) error {
grpcListener, err := net.Listen("tcp", grpcAddr)
if err != nil {
return fmt.Errorf("listen gRPC on %s: %w", grpcAddr, err)
@@ -46,7 +52,7 @@ func (s *Server) Start(grpcAddr, httpAddr string, allow []string) error {
return fmt.Errorf("listen HTTP on %s: %w", httpAddr, err)
}
s.dynamic, err = newDynamicGateway(grpcAddr, allow)
s.dynamic, err = newDynamicGateway(grpcAddr)
if err != nil {
_ = grpcListener.Close()
_ = httpListener.Close()
@@ -65,7 +71,7 @@ func (s *Server) Start(grpcAddr, httpAddr string, allow []string) error {
})
s.http = &http.Server{
Addr: httpAddr,
Handler: handler,
Handler: s.auth.httpMiddleware(handler),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,

View File

@@ -9,7 +9,10 @@ import (
)
func TestHTTPRouterIsAvailable(t *testing.T) {
srv := New()
srv, err := New("0123456789abcdef0123456789abcdef", 3600, []string{"/healthz"})
if err != nil {
t.Fatal(err)
}
srv.HTTP.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusNoContent) })
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)