美文网首页
【Go Web开发】创建用户权限

【Go Web开发】创建用户权限

作者: Go语言由浅入深 | 来源:发表于2022-03-26 08:46 被阅读0次

上一篇文章我们做了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 email ...
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命令添加一些权限,具体如下所示:

执行以下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"
}

相关文章

网友评论

      本文标题:【Go Web开发】创建用户权限

      本文链接:https://www.haomeiwen.com/subject/dimjjrtx.html