提示:本节课最终代码为: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 上搜索下,看有哪些已有的成熟包/框架或者项目可供我们借鉴参考:
通过搜索,发现 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 授权功能开发
通过上面的功能分析我们可以整理出,实现授权功能需要完成的功能和步骤:
- 开发一个 Gin 中间件,用来进行请求授权;
- 创建用户时,添加一条授权策略,授权该用户访问自己的资源(用户+博客)。
在对博客进行 CURD 的时候,是通过以下方式来查询的:
type PostStore interface {
Create(ctx context.Context, post *model.PostM) error
Get(ctx context.Context, username, postID string) (*model.PostM, error)
Update(ctx context.Context, post *model.PostM) error
List(ctx context.Context, username string, offset, limit int) (int64, []*model.PostM, error)
Delete(ctx context.Context, username string, postIDs []string) error
}
可以看到,所有数据库操作都会带上用户名,相当于用户只能操作自己的博客。所以这里不需要再对用户博客的操作权限进行授权。
另外,我们在中间件中需要调用 SyncedEnforcer
实例的 Enforce
方法进行授权,在创建用户的时候,需要用到 SyncedEnforcer
实例的 AddNamedPolicy
方法保存授权策略,为了避免多次创建 SyncedEnforcer
实例,我们可以在 main 函数中创建一个 SyncedEnforcer
实例,并分别传给中间件层和 Controller 层进行调用(以上开发逻辑,是在开发过程不断优化实现方法的最终结果)。
所以最终,我们的开发步骤变成以下 3 步:
- 开发
authz
包,用来创建SyncedEnforcer
实例; - 开发 Gin 授权中间件,并将
SyncedEnforcer
实例传入中间件层使用; - 将
SyncedEnforcer
实例传入 Controller 层用来添加授权策略; - 添加授权测试代码。
下面我们就进入代码开发阶段。
- 开发
authz
包,用来创建SyncedEnforcer
实例。
这里我将认证/授权相关的功能实现,统一放在 pkg/auth
目录下(当然,你可以单独开发一个 authz
包,影响不大),授权功能的实现存放在 pkg/auth/authz.go
文件中,代码如下:
package auth
import (
"time"
casbin "github.com/casbin/casbin/v2"
"github.com/casbin/casbin/v2/model"
adapter "github.com/casbin/gorm-adapter/v3"
"gorm.io/gorm"
)
const (
// casbin 访问控制模型.
aclModel = `[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub == p.sub && keyMatch(r.obj, p.obj) && regexMatch(r.act, p.act)`
)
// Authz 定义了一个授权器,提供授权功能.
type Authz struct {
*casbin.SyncedEnforcer
}
// NewAuthz 创建一个使用 casbin 完成授权的授权器.
func NewAuthz(db *gorm.DB) (*Authz, error) {
// Initialize a Gorm adapter and use it in a Casbin enforcer
adapter, err := adapter.NewAdapterByDB(db)
if err != nil {
return nil, err
}
m, _ := model.NewModelFromString(aclModel)
// Initialize the enforcer.
enforcer, err := casbin.NewSyncedEnforcer(m, adapter)
if err != nil {
return nil, err
}
// Load the policy from DB.
if err := enforcer.LoadPolicy(); err != nil {
return nil, err
}
enforcer.StartAutoLoadPolicy(5 * time.Second)
a := &Authz{enforcer}
return a, nil
}
// Authorize 用来进行授权.
func (a *Authz) Authorize(sub, obj, act string) (bool, error) {
return a.Enforce(sub, obj, act)
}
因为我们采用了 RESTful 访问控制模型,其授权模型基本上不会再改变,所以这里我选择在代码中硬编码,授权模型使用了官方的示例模型:keymatch_model.conf。当然,你可以根据需要修改授权模型。
为了方便未来扩展,这里我并没有直接使用 casbin.SyncedEnforcer
,而是基于 casbin.SyncedEnforcer
封装了 Authz
,后续可以给 Authz
结构体添加更多的方法,来扩展授权功能。
因为我们在 main 函数中已经创建过 *gorm.DB
实例,所以这里我们直接使用 NewAdapterByDB
来创建 SyncedEnforcer
实例,而不通过 NewAdapter
来创建(前提:创建方法你通过阅读 Go 每日一库之 casbin 或者 casbin/gorm-adapter README 文件已经掌握)。
为了能够快速将数据库中保存的授权策略加载到缓存中,供授权时使用,这里我们设置了每 5s 从数据库中同步一次授权策略:
enforcer.StartAutoLoadPolicy(5 * time.Second)
最后,我们提供了 Authorize
方法来进行授权,Authorize
方法简单封装了 SyncedEnforcer
实例的 Enforce
方法。没有直接使用 Enforce
方法,是因为 Authorize
接口语义更明确。
- 开发 Gin 授权中间件,并将
SyncedEnforcer
实例传入中间件层使用。
新建 internal/pkg/middleware/authz.go
文件,代码内容如下:
type Auther interface {
Authorize(sub, obj, act string) (bool, error)
}
// Authz 是 Gin 中间件,用来进行请求授权.
func Authz(a Auther) gin.HandlerFunc {
return func(c *gin.Context) {
sub := c.GetString(known.XUsernameKey)
obj := c.Request.URL.Path
act := c.Request.Method
log.Debugw("Build authorize context", "sub", sub, "obj", obj, "act", act)
if allowed, _ := a.Authorize(sub, obj, act); !allowed {
core.WriteResponse(c, errno.ErrUnauthorized, nil)
c.Abort()
return
}
}
}
Authz
是一个 Gin 授权中间件,参数为 Auther
接口类型,之所以不是 *auth.Authz
类型,是因为期望 Authz
中间件能够变得更加独立(不依赖 github.com/nosbelm/miniblog/pkg/auth
包)、规范、可扩展。
我们从 *gin.Context
中解析出了:用户名、访问路径、HTTP 方法,分别作为 casbin 授权模型中的 sub
、obj
、act
。
最后,调用 Auther
接口的 Authorize
方法进行访问授权,授权失败返回 errno.ErrUnauthorized
错误码,并调用 c.Abort()
终止请求。
- 将
SyncedEnforcer
实例传入 Controller 层用来添加授权策略。
在 internal/miniblog/router.go
文件中,通过以下代码创建 *auth.Authz
实例:
func installRouters(g *gin.Engine) error {
......
authz, err := auth.NewAuthz(store.S.DB())
if err != nil {
return err
}
uc := user.New(store.S, authz)
g.POST("/login", uc.Login)
// 创建 v1 路由分组
v1 := g.Group("/v1")
{
// 创建 users 路由分组
userv1 := v1.Group("/users")
{
userv1.POST("", uc.Create) // 创建用户
userv1.PUT(":name/change-password", uc.ChangePassword) // 修改用户密码
userv1.Use(mw.Authn(), mw.Authz(authz))
}
}
return nil
}
上述代码,通过 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
路由函数中调用,用来添加授权策略:
if _, err := ctrl.a.AddNamedPolicy("p", r.Username, "/v1/users/"+r.Username, defaultMethods); err != nil {
core.WriteResponse(c, err, nil)
return
}
上述代码,其实是往数据库中插入了一条记录,如下图所示(id=26):
策略语义是:belm
用户对 /v1/users/blem
路径具有 GET
、POST
、PUT
、DELETE
操作。
再结合,Gin 的路径设置,如下图:
我们可以知道,其实上述 casbin 策略更场景化的意思是:允许 belm
用户对 belm
用户执行:获取详情、更新、删除操作。
- 添加授权测试代码。
授权功能添加好后,我们当然要测试授权功能,这里我们可以通过实现 /v1/users/:name
接口来测试。
/v1/users/:name
接口的实现思路,前面已经介绍过多次,这里不再赘述。
编译测试
最终完成后的代码为:feature/s19。
执行以下命令,编译并启动服务:
$ make
$ _output/miniblog -c configs/miniblog.yaml
打开一个新的 Linux 终端,执行以下命令进行测试:
# 创建用户 belma
$ 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
# 创建用户 belmb
$ 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
# belma 用户登录 miniblog 平台
$ token=`curl -s -XPOST -H"Content-Type: application/json" -d'{"username":"belma","password":"miniblog1234"}' http://127.0.0.1:8080/login | jq -r .token`
# belma 获取 belma 的详细信息
$ curl -XGET -H"Authorization: Bearer $token" http://127.0.0.1:8080/v1/users/belma
{"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"}
# belma 获取 belmb 的详细信息
$ curl -XGET -H"Authorization: Bearer $token" http://127.0.0.1:8080/v1/users/belmb
{"code":"AuthFailure.Unauthorized","message":"Unauthorized."}
可以看到,belma
可以获取自己的注册信息,但是获取 belmb
的注册信息时会报授权失败错误:AuthFailure.Unauthorized
。
观察数据库,可以发现数据库新增了以下 2 条新记录:
小结
在实现服务授权功能的时候,首先需要选择一个合适的授权模型,当前最受欢迎的授权模型是 RBAC 模型。在选择了授权模型之后,还需要实现它。一般来说你可以选择一些受欢迎的 Go 包来完成授权功能,而非自己从 0 开发。当前最受欢迎的 Go 授权包是 casbin。casbin 提供了很多开箱即用的功能,具体你可以参考 casbin 官网进行学习。