提示:本节课最终代码为:feature/s19

企业应用中,保障服务安全的另外一个重要手段是服务授权。本节课就来详细介绍下如何实现服务的授权功能。

如何实现服务授权?

要实现服务授权,首先要根据业务选择一个授权模式,不同的权限模型具有不同的特点,可以满足不同的需求。常见的权限模型有下面这 5 种:

  • 权限控制列表(ACL,Access Control List);
  • 自主访问控制(DAC,Discretionary Access Control);
  • 强制访问控制(MAC,Mandatory Access Control);
  • 基于角色的访问控制(RBAC,Role-Based Access Control);
  • 基于属性的权限验证(ABAC,Attribute-Based Access Control)。

当前用的比较多的是 RBAC 模式。关于 RBAC 模式的介绍,网上已经有很多文章,这里不再赘述,这里有一篇文章供你参考:详细了解RBAC(Role-Based Access Control)。RBAC 能够满足绝大部分的企业应用授权场景,例如 Kubernetes 就使用了 RBAC 授权模式。miniblog 也使用了 RBAC 授权模式。

要给 miniblog 添加 RBAC 授权功能,首先要去 GitHub 上搜索下,看有哪些已有的成熟包/框架或者项目可供我们借鉴参考:

img

通过搜索,发现 casbin 有 13.3k 的 Star,这个量级,已经充分说明了,casbin 可能是非常成熟、优秀的 RBAC 授权框架。继续搜索 GitHub 上类似的项目,发现 casbin 应该是最适合的。所以接下来,就认真阅读 casbin 官方文档:Casbin 官方文档。通过阅读官方文档,我们能够获取以下有用信息:

  • casbin 提供一个在线编辑器,可以用来进行权限验证:Editor | Casbin
  • 有很多基于 casbin 实现授权的文章和课程:使用指南
  • casbin 授权依赖 Model 文件来描述授权模式,依赖 Policy 文件来指定权限。并且官网提供了不同授权模式的 Model 文件和 Policy 文件示例:支持的模型
  • casbin 的 Model 文件和 Policy 文件可以从不同的位置加载;
  • casbin 针对不同的 Web 框架已经有相应的中间件实现:中间件
  • 当然,还有很多其他有用的文档。

以上这些是我通过阅读官网文档,整理的可能对我们实现授权功能有用的内容(你也会根据自己的理解整理出不同的文档,并最终梳理出可能的实现方法)。

提示:通过阅读官方文档,我们还知道有个官方 casbin-server,用来实现 Casbin as a Service (CaaS)。有个 Casdoor 门户网站用于模型管理和策略管理。这些我们暂时用不上,但是可以加入我们脑子中的代码库,将来用到的时候,再详细研究。

看了这么多文档,其实我们对 casbin 已经有不错的理解了,但还是不知道怎么用 casbin,为什么呢?因为我们并没有实际去动手编码,实际体验 casbin 的使用方法、执行效果,我们内心很虚。

所以,接下来,我们需要从官方文档上或者 GitHub 上寻找一些短小、精湛,能够让我们快速试验的 RBAC 授权示例。在官方文档 使用指南 中,我发现了一篇不错的实战文档:Go 每日一库之 casbin。根据文档,进行编码实战后,基本上知道怎么使用 casbin 来进行授权了。

在充分学习了 casbin 及其使用方法之后,接下来就可以进行代码开发了。

miniblog 授权实现

在开始开发授权功能前,我们需要梳理下具体要实现什么样的授权功能。

miniblog 需要实现什么样的授权功能

首先,我们选择 RBAC 模型,因为 RBAC 模型目前用的最多,适配的场景也很多,对于一般的企业应用基本都能满足。

另外,对于一个企业级应用,权限策略肯定是保存在持久化的存储中,根据之前的学习,我们知道 casbin 可以将权限策略通过各种 适配器 保存在 MySQL、PostgreSQL、MariaDB、SQLite3 等后端存储中,这里我们自然要选择可读性、维护性最好的 MySQL 数据库中。并且因为我们是用的 GORM,所以可以使用官网提供的 Gorm Adapter。这时候,你自然会根据 casbin/gorm-adapter 包中提供的示例,去实际操作、验证、学习一番。

我们实现什么样的授权功能呢?这个根据业务需求,会差别很大。在 miniblog 项目中,为了能够给你展示如何开发授权功能,并且不引人过于复杂的授权场景,我选择了对用户的资源操作进行授权:用户只能访问自己账户下的用户/博客资源,管理员(root 用户)可以访问用户列表。很自然,我们会想到对 API 路径进行授权(因为 API 路径中包含了用户名)。通过阅读 casbin 官方文档我们知道, casbin 支持 RESTful 访问控制模型,其策略语法示例如下:

A B C D
p alice /alice_data/* GET
p alice /alice_data/resource1 POST
p bob /alice_data/resource2 GET
p bob /bob_data/* POST
p cathy /cathy_data (GET)(POST)

提示:为了降低课程复杂度,这里没有选择 RBAC 授权场景。

据此,我们可以根据博客系统的功能列出授权策略示例:

A B C D
p root /v1/users* (GET)(POST)(PUT)(DELETE)
p bob /v1/users/belm (GET)(POST)(PUT)(DELETE)

因为,我们要对每一个 HTTP 请求进行授权,所以授权功能很适合通过 Gin 中间件来实现。

miniblog 授权功能开发

通过上面的功能分析我们可以整理出,实现授权功能需要完成的功能和步骤:

  1. 开发一个 Gin 中间件,用来进行请求授权;
  2. 创建用户时,添加一条授权策略,授权该用户访问自己的资源(用户+博客)。

在对博客进行 CURD 的时候,是通过以下方式来查询的:

  1. type PostStore interface {
  2. Create(ctx context.Context, post *model.PostM) error
  3. Get(ctx context.Context, username, postID string) (*model.PostM, error)
  4. Update(ctx context.Context, post *model.PostM) error
  5. List(ctx context.Context, username string, offset, limit int) (int64, []*model.PostM, error)
  6. Delete(ctx context.Context, username string, postIDs []string) error
  7. }

可以看到,所有数据库操作都会带上用户名,相当于用户只能操作自己的博客。所以这里不需要再对用户博客的操作权限进行授权。

另外,我们在中间件中需要调用 SyncedEnforcer 实例的 Enforce 方法进行授权,在创建用户的时候,需要用到 SyncedEnforcer 实例的 AddNamedPolicy 方法保存授权策略,为了避免多次创建 SyncedEnforcer 实例,我们可以在 main 函数中创建一个 SyncedEnforcer 实例,并分别传给中间件层和 Controller 层进行调用(以上开发逻辑,是在开发过程不断优化实现方法的最终结果)。

所以最终,我们的开发步骤变成以下 3 步:

  1. 开发 authz 包,用来创建 SyncedEnforcer 实例;
  2. 开发 Gin 授权中间件,并将 SyncedEnforcer 实例传入中间件层使用;
  3. SyncedEnforcer 实例传入 Controller 层用来添加授权策略;
  4. 添加授权测试代码。

下面我们就进入代码开发阶段。

  1. 开发 authz 包,用来创建 SyncedEnforcer 实例。

这里我将认证/授权相关的功能实现,统一放在 pkg/auth 目录下(当然,你可以单独开发一个 authz 包,影响不大),授权功能的实现存放在 pkg/auth/authz.go 文件中,代码如下:

  1. package auth
  2. import (
  3. "time"
  4. casbin "github.com/casbin/casbin/v2"
  5. "github.com/casbin/casbin/v2/model"
  6. adapter "github.com/casbin/gorm-adapter/v3"
  7. "gorm.io/gorm"
  8. )
  9. const (
  10. // casbin 访问控制模型.
  11. aclModel = `[request_definition]
  12. r = sub, obj, act
  13. [policy_definition]
  14. p = sub, obj, act
  15. [policy_effect]
  16. e = some(where (p.eft == allow))
  17. [matchers]
  18. m = r.sub == p.sub && keyMatch(r.obj, p.obj) && regexMatch(r.act, p.act)`
  19. )
  20. // Authz 定义了一个授权器,提供授权功能.
  21. type Authz struct {
  22. *casbin.SyncedEnforcer
  23. }
  24. // NewAuthz 创建一个使用 casbin 完成授权的授权器.
  25. func NewAuthz(db *gorm.DB) (*Authz, error) {
  26. // Initialize a Gorm adapter and use it in a Casbin enforcer
  27. adapter, err := adapter.NewAdapterByDB(db)
  28. if err != nil {
  29. return nil, err
  30. }
  31. m, _ := model.NewModelFromString(aclModel)
  32. // Initialize the enforcer.
  33. enforcer, err := casbin.NewSyncedEnforcer(m, adapter)
  34. if err != nil {
  35. return nil, err
  36. }
  37. // Load the policy from DB.
  38. if err := enforcer.LoadPolicy(); err != nil {
  39. return nil, err
  40. }
  41. enforcer.StartAutoLoadPolicy(5 * time.Second)
  42. a := &Authz{enforcer}
  43. return a, nil
  44. }
  45. // Authorize 用来进行授权.
  46. func (a *Authz) Authorize(sub, obj, act string) (bool, error) {
  47. return a.Enforce(sub, obj, act)
  48. }

因为我们采用了 RESTful 访问控制模型,其授权模型基本上不会再改变,所以这里我选择在代码中硬编码,授权模型使用了官方的示例模型:keymatch_model.conf。当然,你可以根据需要修改授权模型。

为了方便未来扩展,这里我并没有直接使用 casbin.SyncedEnforcer,而是基于 casbin.SyncedEnforcer 封装了 Authz,后续可以给 Authz 结构体添加更多的方法,来扩展授权功能。

因为我们在 main 函数中已经创建过 *gorm.DB 实例,所以这里我们直接使用 NewAdapterByDB 来创建 SyncedEnforcer 实例,而不通过 NewAdapter 来创建(前提:创建方法你通过阅读 Go 每日一库之 casbin 或者 casbin/gorm-adapter README 文件已经掌握)。

为了能够快速将数据库中保存的授权策略加载到缓存中,供授权时使用,这里我们设置了每 5s 从数据库中同步一次授权策略:

  1. enforcer.StartAutoLoadPolicy(5 * time.Second)

最后,我们提供了 Authorize 方法来进行授权,Authorize 方法简单封装了 SyncedEnforcer 实例的 Enforce 方法。没有直接使用 Enforce 方法,是因为 Authorize 接口语义更明确。

  1. 开发 Gin 授权中间件,并将 SyncedEnforcer 实例传入中间件层使用。

新建 internal/pkg/middleware/authz.go 文件,代码内容如下:

  1. type Auther interface {
  2. Authorize(sub, obj, act string) (bool, error)
  3. }
  4. // Authz 是 Gin 中间件,用来进行请求授权.
  5. func Authz(a Auther) gin.HandlerFunc {
  6. return func(c *gin.Context) {
  7. sub := c.GetString(known.XUsernameKey)
  8. obj := c.Request.URL.Path
  9. act := c.Request.Method
  10. log.Debugw("Build authorize context", "sub", sub, "obj", obj, "act", act)
  11. if allowed, _ := a.Authorize(sub, obj, act); !allowed {
  12. core.WriteResponse(c, errno.ErrUnauthorized, nil)
  13. c.Abort()
  14. return
  15. }
  16. }
  17. }

Authz 是一个 Gin 授权中间件,参数为 Auther 接口类型,之所以不是 *auth.Authz 类型,是因为期望 Authz 中间件能够变得更加独立(不依赖 github.com/nosbelm/miniblog/pkg/auth 包)、规范、可扩展。

我们从 *gin.Context 中解析出了:用户名、访问路径、HTTP 方法,分别作为 casbin 授权模型中的 subobjact

最后,调用 Auther 接口的 Authorize 方法进行访问授权,授权失败返回 errno.ErrUnauthorized 错误码,并调用 c.Abort() 终止请求。

  1. SyncedEnforcer 实例传入 Controller 层用来添加授权策略。

internal/miniblog/router.go 文件中,通过以下代码创建 *auth.Authz 实例:

  1. func installRouters(g *gin.Engine) error {
  2. ......
  3. authz, err := auth.NewAuthz(store.S.DB())
  4. if err != nil {
  5. return err
  6. }
  7. uc := user.New(store.S, authz)
  8. g.POST("/login", uc.Login)
  9. // 创建 v1 路由分组
  10. v1 := g.Group("/v1")
  11. {
  12. // 创建 users 路由分组
  13. userv1 := v1.Group("/users")
  14. {
  15. userv1.POST("", uc.Create) // 创建用户
  16. userv1.PUT(":name/change-password", uc.ChangePassword) // 修改用户密码
  17. userv1.Use(mw.Authn(), mw.Authz(authz))
  18. }
  19. }
  20. return nil
  21. }

上述代码,通过 auth.NewAuthz(store.S.DB()) 创建 *auth.Authz 实例 authz。为了复用已经初始化好的 *gorm.DB 实例,我们为 IStore 接口添加了一个 DB() *gorm.DB 方法,用来获取已经初始化过的 *gorm.DB 实例。

上述代码,通过 userv1.Use(mw.Authn(), mw.Authz(authz)) 来给 userv1 路由组添加认证和授权中间件,这里要注意中间件添加顺序:

  • 创建用户时,不需要认证和授权,否则用户会无法注册;
  • 修改密码时,不需要认证和授权,因为 ChangePassword 接口实现中,会进行认证。因为不是访问资源,所以无需授权;
  • 认证要在授权之前,因为只有通过认证,我们才能获取到用户名,并将用户名添加到 *gin.Context 中,供授权时使用。

在调用 user.New(store.S, authz) 时传入 authz 参数,并在 Create 路由函数中调用,用来添加授权策略:

  1. if _, err := ctrl.a.AddNamedPolicy("p", r.Username, "/v1/users/"+r.Username, defaultMethods); err != nil {
  2. core.WriteResponse(c, err, nil)
  3. return
  4. }

上述代码,其实是往数据库中插入了一条记录,如下图所示(id=26):

img

策略语义是:belm 用户对 /v1/users/blem 路径具有 GETPOSTPUTDELETE 操作。

再结合,Gin 的路径设置,如下图:

img

我们可以知道,其实上述 casbin 策略更场景化的意思是:允许 belm 用户对 belm 用户执行:获取详情、更新、删除操作。

  1. 添加授权测试代码。

授权功能添加好后,我们当然要测试授权功能,这里我们可以通过实现 /v1/users/:name 接口来测试。

/v1/users/:name 接口的实现思路,前面已经介绍过多次,这里不再赘述。

编译测试

最终完成后的代码为:feature/s19

执行以下命令,编译并启动服务:

  1. $ make
  2. $ _output/miniblog -c configs/miniblog.yaml

打开一个新的 Linux 终端,执行以下命令进行测试:

  1. # 创建用户 belma
  2. $ curl -XPOST -H"Content-Type: application/json" -d'{"username":"belma","password":"miniblog1234","nickname":"belma","email":"nosbelma@qq.com","phone":"18188888xxx"}' http://127.0.0.1:8080/v1/users
  3. # 创建用户 belmb
  4. $ curl -XPOST -H"Content-Type: application/json" -d'{"username":"belmb","password":"miniblog1234","nickname":"belmb","email":"nosbelmb@qq.com","phone":"18188888xxx"}' http://127.0.0.1:8080/v1/users
  5. # belma 用户登录 miniblog 平台
  6. $ token=`curl -s -XPOST -H"Content-Type: application/json" -d'{"username":"belma","password":"miniblog1234"}' http://127.0.0.1:8080/login | jq -r .token`
  7. # belma 获取 belma 的详细信息
  8. $ curl -XGET -H"Authorization: Bearer $token" http://127.0.0.1:8080/v1/users/belma
  9. {"username":"belma","nickname":"belma","email":"nosbelma@qq.com","phone":"18188888xxx","postCount":0,"createdAt":"2022-12-07 09:47:04","updatedAt":"2022-12-07 09:47:04"}
  10. # belma 获取 belmb 的详细信息
  11. $ curl -XGET -H"Authorization: Bearer $token" http://127.0.0.1:8080/v1/users/belmb
  12. {"code":"AuthFailure.Unauthorized","message":"Unauthorized."}

可以看到,belma 可以获取自己的注册信息,但是获取 belmb 的注册信息时会报授权失败错误:AuthFailure.Unauthorized

观察数据库,可以发现数据库新增了以下 2 条新记录:

img

小结

在实现服务授权功能的时候,首先需要选择一个合适的授权模型,当前最受欢迎的授权模型是 RBAC 模型。在选择了授权模型之后,还需要实现它。一般来说你可以选择一些受欢迎的 Go 包来完成授权功能,而非自己从 0 开发。当前最受欢迎的 Go 授权包是 casbin。casbin 提供了很多开箱即用的功能,具体你可以参考 casbin 官网进行学习。