feat: add authz-adapter service and Envoy ext_authz integration
- Implemented authz-adapter deployment and service for Envoy gRPC authorization. - Created PowerShell script to generate JWK for JWT authentication. - Documented the integration of ext_authz with user-rpc.ValidateToken in ENVOY_EXT_AUTHZ_ADAPTER.md. - Added comprehensive Envoy Gateway configuration guide with JWT authentication and access control in ENVOY_GATEWAY_GUIDE.md.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# authz-adapter
|
||||
|
||||
Envoy `ext_authz` 适配服务,实现 `envoy.service.auth.v3.Authorization`,并调用 `user-rpc.ValidateToken`。
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `LISTEN_ON`:监听地址,默认 `0.0.0.0:9002`
|
||||
- `USER_RPC_TARGET`:user-rpc 地址,默认 `user-rpc-svc.juwan.svc.cluster.local:9001`
|
||||
|
||||
## 本地运行
|
||||
|
||||
```powershell
|
||||
go run ./app/authz/adapter
|
||||
```
|
||||
|
||||
## Docker 构建
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
docker build -f app/authz/adapter/Dockerfile -t authz-adapter:local .
|
||||
docker run --rm -p 9002:9002 authz-adapter:local
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
- 放行路径:`/healthz`、`/api/users/login`、`/api/users/register`
|
||||
- 受保护路径:其余请求要求
|
||||
- Cookie 中有 `JToken`
|
||||
- Header 中有 `x-auth-user-id`(由 Envoy `jwt_authn` 注入)
|
||||
- 鉴权通过后回传:`x-auth-user-id`、`x-auth-role-type`
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
userpb "juwan-backend/app/users/rpc/pb"
|
||||
|
||||
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
|
||||
authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
|
||||
typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
|
||||
codepb "google.golang.org/genproto/googleapis/rpc/code"
|
||||
statuspb "google.golang.org/genproto/googleapis/rpc/status"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const (
|
||||
headerAuthUserID = "x-auth-user-id"
|
||||
headerAuthRoleType = "x-auth-role-type"
|
||||
headerAuthIsAdmin = "x-auth-is-admin"
|
||||
headerCookie = "cookie"
|
||||
cookieJToken = "JToken"
|
||||
)
|
||||
|
||||
type authzServer struct {
|
||||
authv3.UnimplementedAuthorizationServer
|
||||
userRPC userpb.UsercenterClient
|
||||
}
|
||||
|
||||
func (s *authzServer) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3.CheckResponse, error) {
|
||||
httpReq := req.GetAttributes().GetRequest().GetHttp()
|
||||
if httpReq == nil {
|
||||
return deny(codepb.Code_INVALID_ARGUMENT, typev3.StatusCode_BadRequest, "missing http attributes"), nil
|
||||
}
|
||||
|
||||
path := httpReq.GetPath()
|
||||
if isPublicPath(path) {
|
||||
return allow(nil), nil
|
||||
}
|
||||
|
||||
token, ok := getCookieValue(httpReq.GetHeaders(), cookieJToken)
|
||||
if !ok || token == "" {
|
||||
return deny(codepb.Code_UNAUTHENTICATED, typev3.StatusCode_Unauthorized, "missing JToken cookie"), nil
|
||||
}
|
||||
|
||||
userIDHeader := getHeader(httpReq.GetHeaders(), headerAuthUserID)
|
||||
if userIDHeader == "" {
|
||||
return deny(codepb.Code_UNAUTHENTICATED, typev3.StatusCode_Unauthorized, "missing x-auth-user-id header"), nil
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseInt(userIDHeader, 10, 64)
|
||||
if err != nil || userID <= 0 {
|
||||
return deny(codepb.Code_UNAUTHENTICATED, typev3.StatusCode_Unauthorized, "invalid x-auth-user-id"), nil
|
||||
}
|
||||
|
||||
rpcCtx, cancel := context.WithTimeout(ctx, 1200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
resp, err := s.userRPC.ValidateToken(rpcCtx, &userpb.ValidateTokenReq{
|
||||
Token: token,
|
||||
UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return deny(codepb.Code_UNAUTHENTICATED, typev3.StatusCode_Unauthorized, "validate token failed"), nil
|
||||
}
|
||||
if !resp.GetValid() {
|
||||
return deny(codepb.Code_PERMISSION_DENIED, typev3.StatusCode_Forbidden, "token invalid"), nil
|
||||
}
|
||||
|
||||
outHeaders := []*corev3.HeaderValueOption{
|
||||
{Header: &corev3.HeaderValue{Key: headerAuthUserID, Value: strconv.FormatInt(resp.GetUserId(), 10)}},
|
||||
{Header: &corev3.HeaderValue{Key: headerAuthRoleType, Value: strconv.FormatInt(resp.GetRoleType(), 10)}},
|
||||
}
|
||||
|
||||
if getHeader(httpReq.GetHeaders(), headerAuthIsAdmin) != "" {
|
||||
outHeaders = append(outHeaders, &corev3.HeaderValueOption{Header: &corev3.HeaderValue{Key: headerAuthIsAdmin, Value: getHeader(httpReq.GetHeaders(), headerAuthIsAdmin)}})
|
||||
}
|
||||
|
||||
return allow(outHeaders), nil
|
||||
}
|
||||
|
||||
func allow(headers []*corev3.HeaderValueOption) *authv3.CheckResponse {
|
||||
return &authv3.CheckResponse{
|
||||
Status: &statuspb.Status{Code: int32(codepb.Code_OK)},
|
||||
HttpResponse: &authv3.CheckResponse_OkResponse{
|
||||
OkResponse: &authv3.OkHttpResponse{Headers: headers},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func deny(code codepb.Code, httpCode typev3.StatusCode, message string) *authv3.CheckResponse {
|
||||
return &authv3.CheckResponse{
|
||||
Status: &statuspb.Status{Code: int32(code), Message: message},
|
||||
HttpResponse: &authv3.CheckResponse_DeniedResponse{
|
||||
DeniedResponse: &authv3.DeniedHttpResponse{
|
||||
Status: &typev3.HttpStatus{Code: httpCode},
|
||||
Body: fmt.Sprintf(`{"code":%d,"message":"%s"}`, httpCode, message),
|
||||
Headers: []*corev3.HeaderValueOption{
|
||||
{Header: &corev3.HeaderValue{Key: "content-type", Value: "application/json"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func isPublicPath(path string) bool {
|
||||
if path == "/healthz" || path == "/api/users/login" || path == "/api/users/register" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getHeader(headers map[string]string, key string) string {
|
||||
for k, v := range headers {
|
||||
if strings.EqualFold(k, key) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getCookieValue(headers map[string]string, name string) (string, bool) {
|
||||
cookieHeader := getHeader(headers, headerCookie)
|
||||
if cookieHeader == "" {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.Split(cookieHeader, ";")
|
||||
for _, part := range parts {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
if kv[0] == name {
|
||||
return kv[1], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func getEnvWithDefault(key, defaultValue string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func run() error {
|
||||
listenOn := getEnvWithDefault("LISTEN_ON", "0.0.0.0:9002")
|
||||
userRPCTarget := getEnvWithDefault("USER_RPC_TARGET", "user-rpc-svc.juwan.svc.cluster.local:9001")
|
||||
|
||||
conn, err := grpc.NewClient(userRPCTarget, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial user rpc failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
lis, err := net.Listen("tcp", listenOn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen failed: %w", err)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
authv3.RegisterAuthorizationServer(grpcServer, &authzServer{userRPC: userpb.NewUsercenterClient(conn)})
|
||||
|
||||
fmt.Printf("authz-adapter listening on %s, user-rpc target %s\n", listenOn, userRPCTarget)
|
||||
return grpcServer.Serve(lis)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
if !errors.Is(err, net.ErrClosed) {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,25 +23,25 @@ func LoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
|
||||
l := user.NewLoginLogic(r.Context(), svcCtx)
|
||||
resp, err := l.Login(&req)
|
||||
token := resp.Token
|
||||
resp.Token = ""
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "JToken",
|
||||
Value: token,
|
||||
Quoted: false,
|
||||
Path: "/",
|
||||
Domain: "",
|
||||
RawExpires: "",
|
||||
MaxAge: 691200,
|
||||
Secure: false,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Partitioned: false,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
} else {
|
||||
token := resp.Token
|
||||
resp.Token = ""
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "JToken",
|
||||
Value: token,
|
||||
Quoted: false,
|
||||
Path: "/",
|
||||
Domain: "",
|
||||
RawExpires: "",
|
||||
MaxAge: 691200,
|
||||
Secure: false,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Partitioned: false,
|
||||
})
|
||||
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,9 @@ func RegisterHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
resp, err := l.Register(&req)
|
||||
|
||||
if err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
httpx.ErrorCtx(r.Context(), w, utils.NewErrorResp(400, err))
|
||||
} else {
|
||||
httpx.OkJsonCtx(r.Context(), w, utils.NewErrorResp(400, err))
|
||||
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +38,17 @@ func (l *LoginLogic) Login(req *types.LoginReq) (resp *types.LoginResp, err erro
|
||||
Username: req.Username,
|
||||
Passwd: req.Password,
|
||||
})
|
||||
logx.Infof("res:%v", res)
|
||||
if err != nil {
|
||||
logx.Errorf("rpc login err: %v", err)
|
||||
return nil, errors.New("login fail")
|
||||
}
|
||||
|
||||
if res == nil || res.Id <= 0 || res.Username == "" || res.Token == "" {
|
||||
logx.Errorf("rpc login returned empty payload, username=%s, resp=%+v", req.Username, res)
|
||||
return nil, errors.New("login fail")
|
||||
}
|
||||
|
||||
return &types.LoginResp{
|
||||
UserId: res.Id,
|
||||
Username: res.Username,
|
||||
|
||||
@@ -59,7 +59,7 @@ func (l *RegisterLogic) Register(req *types.RegisterReq) (resp *types.RegisterRe
|
||||
|
||||
requestId, err := contextx.RequestIdFrom(l.ctx)
|
||||
if err != nil {
|
||||
logx.Errorf("contextx.RequestIdFrom failed: %v", errjA)
|
||||
logx.Errorf("contextx.RequestIdFrom failed: %v", err)
|
||||
return nil, errors.New("contextx.RequestIdFrom failed")
|
||||
}
|
||||
|
||||
|
||||
@@ -28,3 +28,6 @@ CacheConf:
|
||||
Jwt:
|
||||
SecretKey: "${JWT_SECRET_KEY}"
|
||||
Issuer: "juwan-user-rpc"
|
||||
|
||||
Log:
|
||||
Level: info
|
||||
@@ -31,6 +31,7 @@ func (l *LoginLogic) Login(in *pb.LoginReq) (*pb.LoginResp, error) {
|
||||
logx.WithContext(l.ctx).Errorf("LoginLogic.Login error:%v", err)
|
||||
return nil, err
|
||||
}
|
||||
logx.Infof("user:%v", user)
|
||||
if !utils.VerifyPassword(user.Passwd, in.Passwd) {
|
||||
logx.WithContext(l.ctx).Errorf("User %s Login failed", user.Username)
|
||||
return nil, errors.New("incorrect password")
|
||||
|
||||
@@ -2,7 +2,6 @@ package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"juwan-backend/app/users/rpc/internal/svc"
|
||||
"juwan-backend/app/users/rpc/pb"
|
||||
@@ -27,8 +26,8 @@ func NewValidateTokenLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Val
|
||||
}
|
||||
|
||||
func (l *ValidateTokenLogic) ValidateToken(in *pb.ValidateTokenReq) (*pb.ValidateTokenResp, error) {
|
||||
redisKey := fmt.Sprintf(USER_TOKEN_TEMP, in.UserId)
|
||||
_, err := l.svcCtx.JwtManager.Valid(l.ctx, redisKey)
|
||||
|
||||
_, err := l.svcCtx.JwtManager.Valid(l.ctx, in.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user