结合 casbin 为 APISIX 开发一个接口权限校验插件

时间:2022-10-09 11:16:20

APISIX 插件开发

Apache APISIX 是 Apache 软件基金会下的云原生 API 网关,它兼具动态、实时、高性能等特点,提供了负载均衡、动态上游、灰度发布(金丝雀发布)、服务熔断、身份认证、可观测性等丰富的流量管理功能。我们可以使用 Apache APISIX 来处理传统的南北向流量,也可以处理服务间的东西向流量。同时,它也支持作为 K8s Ingress Controller 来使用。

关于这个网关系统的其他知识领域,还请自行搜索查阅,本文重点介绍如何开发一个 APISIX 的插件。

本次开发的插件使用 casbin 进行接口权限校验及管理维护。

APISIX 为我们提供了 Go 语言开发插件的方式 Go Plugin Runner,将 Go 语言开发的插件程序启动为子程序。

该子进程与 APISIX 进程从属相同用户。当重启或者重新加载 APISIX 时,该 Plugin Runner 也将被重启。

一旦你为指定路由配置了 ext-plugin-* 插件, 匹配该路由的请求将触发从 APISIX 到 Plugin Runner 的 RPC 调用。

Plugin Runner 将处理该 RPC 调用,在其侧创建一个请求,运行 External Plugin 并将结果返回给 APISIX 。

External Plugin 及其执行顺序在这里 ext-plugin-* 配置。与其他插件一样, External Plugin 可以动态启用和重新配置。

废话不多说,下面就直接看代码吧。

代码结构

官方案例地址:​​https://github.com/apache/apisix-go-plugin-runner​

本文案例代码地址:​https://github.com/lanyulei/apisix-go-plugin-runner-casbin​

下面添加注释的,均为我自行开发或者添加的代码。其他均为项目原始文件。

.
├── CHANGELOG.md
├── LICENSE
├── Makefile
├── NOTICE
├── README.md
├── ci
│ ├── apisix
│ │ └── config.yaml
│ ├── docker-compose.yml
│ └── openresty
│ └── nginx.conf
├── cmd
│ └── go-runner
│ ├── main.go
│ ├── main_test.go
│ ├── plugins
│ │ └── permission.go // 插件相关代码,注册、接口或流量处理,均在此处。
│ └── version.go
├── config // 自行添加的配置文件
│ ├── config.yml // jwt、数据库等之类的配置,其中需要加密的配置,还请根据公司内部规则自行进行加密配置
│ └── rbac_model.conf // casbin 权限文件
├── docs
│ ├── assets
│ │ └── images
│ │ └── runner-overview.png
│ └── en
│ └── latest
│ ├── config.json
│ ├── developer-guide.md
│ └── getting-started.md
├── docs.md
├── go-runner
├── go.mod
├── go.sum
├── internal
│ ├── http
│ │ ├── header.go
│ │ ├── req-response.go
│ │ ├── req-response_test.go
│ │ ├── request.go
│ │ ├── request_test.go
│ │ ├── response.go
│ │ └── response_test.go
│ ├── plugin
│ │ ├── conf.go
│ │ ├── conf_test.go
│ │ ├── plugin.go
│ │ └── plugin_test.go
│ ├── server
│ │ ├── error.go
│ │ ├── error_test.go
│ │ ├── server.go
│ │ └── server_test.go
│ └── util
│ ├── msg.go
│ └── pool.go
├── pkg
│ ├── common
│ │ └── error.go
│ ├── db // 数据库连接封装
│ │ └── conn.go
│ ├── http
│ │ └── http.go
│ ├── httptest
│ │ └── recorder.go
│ ├── jwtauth // jwt token 解析校验封装
│ │ └── jwt.go
│ ├── log
│ │ └── log.go
│ ├── permission // casbin 接口权限校验封装
│ │ └── casbin.go
│ ├── plugin
│ │ └── plugin.go
│ ├── redis // redis 连接及常见使用方法封装
│ │ ├── client.go
│ │ └── interface.go
│ └── runner
│ └── runner.go
├── tests
│ └── e2e
│ ├── go.mod
│ ├── go.sum
│ ├── plugins
│ │ ├── plugins_fault_injection_test.go
│ │ ├── plugins_limit_req_test.go
│ │ ├── plugins_response_rewrite_test.go
│ │ ├── plugins_say_test.go
│ │ └── plugins_suite_test.go
│ └── tools
│ └── tools.go
└── tmp
└── build-errors.log

代码详解

数据库与 redis 封装,不在本文进行介绍,本案例代码将上传到 github,还请自行研究查看。

cmd/go-runner/plugins/permission.go

package plugins

import (
"encoding/json""errors""fmt""github.com/apache/apisix-go-plugin-runner/pkg/jwtauth""github.com/apache/apisix-go-plugin-runner/pkg/permission""net/http""strings"

pkgHTTP "github.com/apache/apisix-go-plugin-runner/pkg/http""github.com/apache/apisix-go-plugin-runner/pkg/log""github.com/apache/apisix-go-plugin-runner/pkg/plugin"
)

func init() {
// 注册插件到 APISIXerr := plugin.RegisterPlugin(&Permission{})
if err != nil {
log.Fatalf("failed to register plugin permission: %s", err)
}
}

// Permission .
type Permission struct {
plugin.DefaultPlugin
}

// PermissionConf 配置,便于内部调用,没有则为空
type PermissionConf struct{}

// Name 必须要实现的方法,此为注册 APISIX 后的插件名称
func (p *Permission) Name() string {
return "permission"
}

// ParseConf 解析配置
func (p *Permission) ParseConf(in []byte) (interface{}, error) {
conf := PermissionConf{}
err := json.Unmarshal(in, &conf)
return conf, err
}

// parseToken 自己封装的,jwt token 解析且验证 token 是否正确的
func (p *Permission) parseToken(r pkgHTTP.Request) (claims *jwtauth.Claims, err error) {
token := r.Header().Get("Authorization")
if token == "" {
err = errors.New("not logged in yet")
return
}

// 按空格分割parts := strings.SplitN(token, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
err = errors.New("the token format is incorrect")
return
}

// parts[1]是获取到的tokenString,我们使用之前定义好的解析JWT的函数来解析它claims, err = jwtauth.ParseToken(parts[1])
if err != nil {
return
}
return
}

// response 自己封装的,负责序列化返回结果的
func (p *Permission) response(code int, message string) (resp []byte) {
resp, _ = json.Marshal(map[string]interface{}{
"code": code,
"message": message,
})
return
}

// RequestFilter 负责筛选接口的
func (p *Permission) RequestFilter(conf interface{}, w http.ResponseWriter, r pkgHTTP.Request) {
/*
http.ResponseWriter 若是调用了它的Write或WriteHeader方法的话,则直接返回,不进行接口转发。
pkgHTTP.Request 请求相关的,通过此可获取请求的地址、参数等信息,也可进行请求头的一些配置
*/
var (
claims *jwtauth.Claimserr errorok boolstatusCode int
)

w.Header().Add("X-Gateway", "true")

// 解析 tokenclaims, err = p.parseToken(r)
if err != nil {
statusCode = 44000 // 登录异常goto write
}

// 验证是否有权限ok, err = permission.CheckPermission(string(r.Path()), r.Method(), claims.Username, claims.IsAdmin)
if !ok || err != nil {
statusCode = 43000 // 无权限goto write
}

write:
// 若是接口校验失败,则走这里。if err != nil {
resp := p.response(statusCode, fmt.Sprintf("Authentication failed, %s", err.Error()))
_, err = w.Write(resp)
if err != nil {
log.Errorf("failed to write: %s", err)
}
}
}

pkg/jwtauth/jwt.go

package jwtauth

import (
"errors""github.com/golang-jwt/jwt""github.com/spf13/viper"
)

type Claims struct {
UserId int `json:"user_id"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
jwt.StandardClaims
}

// ParseToken 解析JWT
func ParseToken(tokenString string) (*Claims, error) {
// 解析token
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (i interface{}, err error) {
return []byte(viper.GetString("jwt.secret")), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid { // 校验token
return claims, nil
}
return nil, errors.New("invalid token")
}

pkg/permission/casbin.go

package permission

import (
"fmt""github.com/apache/apisix-go-plugin-runner/pkg/db""github.com/apache/apisix-go-plugin-runner/pkg/log""github.com/apache/apisix-go-plugin-runner/pkg/redis"redis2 "github.com/go-redis/redis""time"

"github.com/casbin/casbin/v2"gormAdapter "github.com/casbin/gorm-adapter/v3""github.com/spf13/viper"
)

const (
loadKey = "load:casbin:data"
)

var enforcer *casbin.SyncedEnforcer

func Setup() {
setEnforcer() // 创建权限实例loadPermission() // 启动插件时首次加载权限authLoadPermission() // 定期及watch变化同步权限
}

// loadPermission 将权限加载到本地缓存中
func loadPermission() {
err := Enforcer().LoadPolicy()
if err != nil {
log.Fatalf("从数据库加载策略失败,错误:%v", err)
}
}

// authLoadPermission 定期同步 casbin 接口校验数据,同时,通过 redis 发布/订阅机制 watch 变化更新 casbin 缓存
func authLoadPermission() {
// 定时同步策略if viper.GetBool("casbin.isTiming") {
// 间隔多长时间同步一次权限策略,单位:秒Enforcer().StartAutoLoadPolicy(time.Second * time.Duration(viper.GetInt("casbin.intervalTime")))
}

// Watch 权限go func() {
pubsub := redis.Rc().Subscribe(loadKey)
defer func(pubsub *redis2.PubSub) {
err := pubsub.Close()
if err != nil {
log.Fatalf(err.Error())
}
}(pubsub)
for _ = range pubsub.Channel() {
loadPermission()
}
}()
}

// setEnforcer 生成 casbin 校验实例
func setEnforcer() {
var (
err erroradapter *gormAdapter.Adapter
)
adapter, err = gormAdapter.NewAdapterByDBWithCustomTable(db.Orm(), nil, viper.GetString("casbin.tableName"))
if err != nil {
log.Fatalf("创建 casbin gorm adapter 失败,错误:%v", err)
}

enforcer, err = casbin.NewSyncedEnforcer(viper.GetString("casbin.rbacModel"), adapter)
if err != nil {
log.Fatalf("创建 casbin enforcer 失败,错误:%v", err)
}
}

// CheckPermission 验证接口是否有权限
func CheckPermission(obj, act, sub string, isAdmin bool) (ok bool, err error) {
if isAdmin {
ok = true
} else {
//判断策略中是否存在ok, err = Enforcer().Enforce(sub, obj, act)
if !ok {
err = fmt.Errorf("the interface cannot be called via %s for the time being %s", act, obj)
return
}
}
return
}

// Enforcer 外部调用 casbin 实例,使用此方法调用
func Enforcer() *casbin.SyncedEnforcer {
return enforcer
}

还需要注意,数据库、redis、casbin 相关初始化操作,需要在程序启动之初就需完成,因此需调整 ​​cmd/go-runner/main.go​​​ 中的 ​​newRunCommand​​ 函数如下:

func newRunCommand() *cobra.Command {
var mode RunModecmd := &cobra.Command{
Use: "run",
Short: "run",
PreRun: func(cmd *cobra.Command, args []string) { // 添加此属性配置// 加载配置文件viper.SetConfigFile("/usr/local/apisix/plugin/go-runner/config/config.yml") // 指定配置文件err := viper.ReadInConfig() // 读取配置信息if err != nil { // 读取配置信息失败log.Fatalf("Fatal error config file: %s \n", err)
}

// 初始化数据库连接
db.Setup()

// 初始化 Redis 连接
redis.Setup()

// 加载权限 LoadPermission
permission.Setup()
},
...
}
...
}

至此,此插件相关核心代码介绍完成了。

下面介绍一下如何在 APISIX 进行使用和运行。

编译

插件程序开发完成后,可执行 ​​make build​​ 进行编译生成,可运行的二进制可执行文件。

​make build​​ 为根据当前系统进行编译的,若需要交叉编译,可使用一下方式进行。

以下为编译成 Linux 的可执行文件的方式。

cd cmd/go-runner && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" . && mv go-runner ../.. && cd ../..

其他系统的编译命令与参数,可以参考下面的例子。

# Mac 下编译 Linux 和 Windows 64位可执行程序
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build main.go

# Linux 下编译 Mac 和 Windows 64位可执行程序
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build main.go
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build main.go

# Windows 下编译 Mac 和 Linux 64位可执行程序
SET CGO_ENABLED=0
SET GOOS=darwin
SET GOARCH=amd64
go build main.go
-------------------------------------------------
SET CGO_ENABLED=0
SET GOOS=linux
SET GOARCH=amd64
go build main.go

GOOS:目标平台的操作系统(darwin、freebsd、linux、windows)。 GOARCH:目标平台的体系架构(386、amd64、arm)。 CGO_ENABLED:交叉编译不支持 CGO 所以要禁用它。

部署

部署到 APISIX 中的话,仅需在配置文件中添加如下配置即可。

ext-plugin:
cmd: ["/path/to/apisix-go-plugin-runner/go-runner", "run"]

APISIX 将插件运行时启动为自己的子进程,管理其整个生命周期。

APISIX 将自动分配一个 unix 套接字地址,以便插件运行时在启动时进行监听及通信。

在上面针对插件的运行,有更详细的描述,请自行翻阅。

Debug

当然,插件开发中,也可以使用下面的方式启动,但需要注意,下面的启动方式,仅建议在开发过程中使用。

首先调整配置文件为如下:

ext-plugin:
path_for_test: /tmp/runner.sock

然后执行如下命令,启动插件:

APISIX_LISTEN_ADDRESS=unix:/tmp/runner.sock ./go-runner run

至此,一个基于 casbin 的 apisix 插件就开发完成了。

若您有其他有意义的想法或者建议,还请提出。单纯扯皮的话,还请高抬贵手,感谢。