上一篇文章我们做了API接口访问权限控制,只有激活的用户才能访问或更新内容,尽管这很有用但有时你可能需要更细粒度地对接口访问进行控制。比如在前面的例子中,我们希望激活的用户可以访问读数据的API接口,但是对更新数据的接口只希望对部分用户开放。
本节,我们将在应用程序中引入权限的概念,只有特定权限的用户,才能执行特定的操作。在我们的例子中,将创建两类权限:1、movies:read权限:允许用户读或过滤movies数据。2、movies:write权限:允许用户创建、修改和删除movies数据。
所需的权限将与我们的API接口对应关系,如下所示:
Method | URL Pattern | 所需权限 |
---|---|---|
GET | /v1/healthcheck | - |
GET | /v1/movies | movies:read |
POST | /v1/movies | movies:write |
GET | /v1/movies/:id | movies:read |
PATCH | /v1/movies/:id | movies:write |
DELETE | /v1/movies/:id | movies:write |
权限与用户的关系
权限和用户之间的关系是多对多关系的典型例子。一个用户可能有多个权限,相同的权限可能属于多个用户。在关系型数据库(如PostgreSQL)中管理多对多关系的经典方法是在两个实体之间创建一个连接表。下面我们快速解释下它是如何工作的。
假设我们将用户数据存在一个用户表中,它看起来像这样:
id | ... | |
---|---|---|
1 | alice@example.com | ... |
2 | bob@example.com | ... |
我们的权限数据存储在一个权限表中:
id | code |
---|---|
1 | movies:read |
2 | movies:write |
然后我们可以创建一张名为users_permissions的连接表来存储关于哪些用户拥有哪些权限的信息,类似如下:
user_id | permission_id |
---|---|
1 | 1 |
2 | 1 |
2 | 2 |
在上面的例子中,用户alice@example(用户ID:1)只有movies:read权限(权限ID:1),而bob@example.com(用户ID:2)包含movies:read和movies:write权限。
就像我们在前面看到的一对多关系一样,您可能会根据这两种数据库模型来查询这种关联关系。例如,在你的数据库模型中,你可能想要创建以下方法:
PermissionModel.GetAllForUser(user) -查询用户的所有权限
UserModel.GetAllForPermission(permission) -查询具有特定权限的所有用户
创建SQL迁移文件
下面开始创建数据库迁移文件,在数据库中创建permission表和users_permission表,根据前面的方法来操作:
$ migrate create -seq -ext=.sql -dir=./migrations add_permissions
./greenlight/migrations/000006_add_permissions.up.sql
./greenlight/migrations/000006_add_permissions.down.sql
在"up"文件中添加以下SQL语句:
File: migrations/000006_add_permissions.up.sql
CREATE TABLE IF NOT EXISTS permissions(
id bigserial PRIMARY KEY,
code text NOT NULL
);
CREATE TABLE IF NOT EXISTS users_permissions(
user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE,
permission_id bigint NOT NULL REFERENCES permissions ON DELETE CASCADE,
PRIMARY KEY (user_id, permission_id)
);
--- Add the two permissions to the table
INSERT INTO permissions (code)
VALUES
('movies:read'),
('movies:write');
这里有几件重要的事情需要指出:
- PRIMARY KEY (user_id, permission_id)在users_permissions表中设置复合主键,主键由user_id和permission_id两列组成。这么设置主键的话意味着用户/权限组合在表中只会出现一次,不能重复。
- 在创建users_permissions表时,我们使用REFERENCES user语法创建外键,受限于用户表的主键,确保user_id列中的任何值在user表中都有对应数据。类似,使用REFERENCES permissions语法确保perssion_id列在permissions表中也有对应数据。
下面在“down”迁移文件中添加必要的DROP TABLE语句:
DROP TABLE IF EXISTS users_permissions;
DROP TABLE IF EXISTS permissions;
现在已经完成了SQL迁移文件,请继续运行迁移命令:
$ migrate -path=./migrations -database=$GREENLIGHT_DB_DSN up
6/u add_permissions (78.950708ms)
配置权限数据库模型
下面我们在internal/data包添加PermissionModel来对新创建表中数据进行增删改查。目前,我们只需要在数据库模型中添加GetAllForUser()方法来返回特定用户的所有权限。我们的想法是,能够像下面那样在我们的处理程序和中间件中使用它:
//返回ID为1的用户权限列表代码。将返回类似[]string{"movies:read", "movies:write"}结果
app.models.Permission.GetAllForUser(1)
需要为特定用户获取权限代码的SQL语句如下:
SELECT permissions.code
FROM permissions
INNER JOIN users_permissions ON users_permissions.permission_id = permission.id
INNER JOIN users ON users_permissions.user_id = users.id
WHERE users.id = $1
这里我们使用INNER JOIN语句将permission表连接到users_permissions表,接着内连接到users表。然后使用WHERE语句过滤结果,只留下对应用户ID的数据行。
下面创建PermissionModel结构体。先创建internal/data/permissions.go文件
$ touch internal/data/permissions.go
添加以下代码:
File:internal/data/permissions.go
package data
import (
"context"
"database/sql"
"time"
)
//定义Permissions切片,用于存放权限码类似(movies:read和movies:write)
type Permissions []string
//添加帮助函数检查Permissions切片是否包含特定权限码
func (p Permissions) Include(code string) bool {
for i := range p {
if code == p[i] {
return true
}
}
return false
}
//定义PermissionModel类型
type PermissionModel struct {
DB *sql.DB
}
// GetAllForUser()方法返回特定用户权限列表。
func (m PermissionModel)GetAllForUser(userID int64) (Permissions, error) {
query := `
SELECT permissions.code
FROM permissions
INNER JOIN users_permissions ON users_permissions.permission_id = permission.id
INNER JOIN users ON users_permissions.user_id = users.id
WHERE users.id = $1`
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
defer cancel()
rows, err := m.DB.QueryContext(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var permissions Permissions
for rows.Next(){
var permission string
err := rows.Scan(&permission)
if err != nil {
return nil, err
}
permissions = append(permissions, permission)
}
if err = rows.Err(); err != nil {
return nil, err
}
return permissions, nil
}
最后,我们需要将PermissionModel添加到Model结构体,这样在处理程序和中间件中就可以调用它,如下所示:
File: internal/data/models.go
package data
...
type Models struct {
Movies MovieModel
Permissions PermissionModel //添加Permissions字段
Tokens TokenModel
Users UserModel
}
func NewModels(db *sql.DB) Models {
return Models{
Movies: MovieModel{DB: db},
Permissions: PermissionModel{db}, //初始化PermissionModel实例
Tokens: TokenModel{DB: db},
Users: UserModel{DB: db},
}
}
File: cmd/api/errors.go
package main
...
func (app *application)notPermittedResponse(w http.ResponseWriter, r *http.Request) {
message := "your user account doesn't have the necessary permissions to access this resource"
app.errorResponse(w, r, http.StatusForbidden, message)
}
然后,我们到cmd/api/middleware.go文件中创建requirePermission()方法。
我们将在requirePermission()中间件中调用requireActivatedUser()中间件,这样反过来不会忘记调用requireAuthenticatedUser()中间件。这非常重要,这意味着我们在使用requirePermission()中间件的时,实际上将执行3种检查,确保请求是来自认证过(不是匿名用户)、激活的并且有特定权限的用户。
我们到cmd/api/middleware.go文件中添加该中间件。
File:cmd/api/middleware.go
package main
...
//注意该中间件的第一个参数是权限码,要求访问的用户必须具备该权限
func (app *application)requirePermission(code string, next http.HandlerFunc) http.HandlerFunc {
fn := func(w http.ResponseWriter, r *http.Request) {
//从请求上下文中读取用户信息
user := app.contextGetUser(r)
//获取用户权限列表
permissions, err := app.models.Permissions.GetAllForUser(user.ID)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
//检查用户权限列表中是否包含所需权限,如果不存在返回403
if !permissions.Include(code){
app.notPermittedResponse(w, r)
return
}
//否则,就有权限访问接口处理程序
next.ServeHTTP(w, r)
}
//使用requireActivatedUser()封装
return app.requireActivatedUser(fn)
}
一旦完成上面的代码,最后的步骤就是更新路由文件,在某些接口上面使用新创建的中间件。继续并更新路由,这样我们的API就需要对获取movie数据的接口使用“movies:read”权限,对创建、编辑或删除影片的接口使用“movies:write”权限。
File:cmd/api/routes.go
func (app *application) routes() http.Handler {
router := httprouter.New()
router.NotFound = http.HandlerFunc(app.notFoundResponse)
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)
router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.requireActivatedUser(app.healthcheckHandler))
//在/v1/movies**接口上使用requirePermission()中间件。
router.HandlerFunc(http.MethodGet, "/v1/movies", app.requirePermission("movies:read", app.listMoviesHandler))
router.HandlerFunc(http.MethodPost, "/v1/movies", app.requirePermission("movies:write", app.createMovieHandler))
router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.requirePermission("movies:read", app.showMovieHandler))
router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.requirePermission("movies:write", app.updateMovieHandler))
router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.requirePermission("movies:write", app.deleteMovieHandler))
router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler)
return app.recoverPanic(app.rateLimit(app.authenticate(router)))
}
演示
演示该功能有点尴尬,因为我们数据库中的用户目前都没有为他们设置任何权限。
为了演示该功能,我们用psql命令添加一些权限,具体如下所示:
- 激活用户alice@example.com。
- 给所有的用户添加"movies:read"权限
- 给faith@example.com用户"movies:write"权限。
执行以下SQL语句;
-- 设置alice@example.com对于用户activated为true
UPDATE users SET activated = true WHERE email = 'alice@example.com';
-- 设置所有用户都有'movies:read'权限
INSERT INTO users_permissions
SELECT id, (SELECT id FROM permissions WHERE code = 'movies:read') FROM users;
-- 设置faith@example.com具备'movies:write'权限
INSERT INTO users_permissions VALUES (
(SELECT id FROM users WHERE email = 'faith@example.com'),
(SELECT id FROM permissions WHERE code = 'movies:write') );
-- 查询所有激活用户和他们的权限
SELECT email, array_agg(permissions.code) as permissions
FROM permissions
INNER JOIN users_permissions ON users_permissions.permission_id = permissions.id
INNER JOIN users ON users_permissions.user_id = users.id
WHERE users.activated = true
GROUP BY email;
一旦完成,你应该能看到当前激活的用户和他们的权限列表,类似如下:
email | permissions
-------------------+----------------------------
alice@example.com | {movies:read}
faith@example.com | {movies:read,movies:write}
(2 rows)
注意:在最后的SQL查询中,我们使用聚合函数array_agg()和GROUP BY子句将与每个邮件地址相关联的权限输出为一个数组。
既然已经为用户分配了一些权限,我们就可以尝试一下了。
首先我们以alice@example.com用户来发起请求,访问GET /v1/movies/1接口和DELTE /v1/movies/1接口。根据权限控制,alice具备"movies:read"权限,因此第一个接口访问正常,而第二个接口是不能访问的,需要"movies:write"权限。
$ BODY='{"email": "alice@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/tokens/authentication
{
"authentication_token": {
"token": "5CSX27B4ZQX74GTAMEAZVDSNGQ",
"expiry": "2022-01-09T14:43:57.904707+08:00"
}
}
$ curl -H "Authorization: Bearer 5CSX27B4ZQX74GTAMEAZVDSNGQ" localhost:4000/v1/movies/1
{
"movie": {
"id": 1,
"title": "Moana",
"year": 2016,
"runtime": "107 mins",
"genres": [
"animation",
"adventure"
],
"Version": 1
}
}
$ curl -X DELETE -H "Authorization: Bearer 5CSX27B4ZQX74GTAMEAZVDSNGQ" localhost:4000/v1/movies/1
{
"error": "your user account doesn't have the necessary permissions to access this resource"
}
很好,代码功能如我们所期望的一致。DELETE操作被阻止了,因为alice@example.com没有配置movies:write权限。
下面,我们以faith@example.com用户发起相同的请求。这回DELETE操作应该可以正常执行。
$ BODY='{"email": "faith@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/tokens/authentication
{
"authentication_token": {
"token": "TI5HNNK7FJDX4RDJNCY7XV5MTY",
"expiry": "2022-01-09T14:59:24.440441+08:00"
}
}
$ curl -X DELETE -H "Authorization: Bearer TI5HNNK7FJDX4RDJNCY7XV5MTY" localhost:4000/v1/movies/1
{
"message": "movie successfully deleted"
}
网友评论