go

【Go Web开发】用户激活

2022-03-20  本文已影响0人  Go语言由浅入深

上一篇文章我们实现了向用户发送激活token,在这一节中,我们将继续进入实际激活用户的部分。但是在编写代码之前,我想快速讨论一下系统中用户和token之间的关系。我们所了解的在关系数据库术语中称为一对多关系——其中一个用户可能有许多token,但一个token只能属于一个用户。当您有这样的一对多关系时,您可能希望从两个不同的方面对关系执行查询。例如,在我们的例子中,可能想要:

要在代码中实现这些查询,一个清晰的方法是更新你的数据库模型,包括一些额外的方法,像这样:

UserModel.GetForToken(token) → 根据token查询用户信息
TokenModel.GetAllForUser(user) → 根据用户查询所有tokens

这种方法的优点是,返回的实体与模型的主要职责相一致;UserModel方法返回一个用户,而TokenModel方法返回tokens。

创建用户激活处理程序(activateUserHandler)

上面我们从业务逻辑上介绍了查询用户和token的关系模型,下面开始编写用户激活代码。为了实现该功能需要添加PUT /v1/users/activated接口到我们的API服务中。激活流程如下:

1、用户提交激活token明文(从欢迎邮件中获取)到PUT /v1/users/activated接口。

2、服务端校验token明文格式是否正确,如果格式不对返回客户端相应错误。

3、然后调用UserModel.GetForToken()方法,根据token查询到对应的用户信息。如果没有匹配的用户,或者token已经过期,向客户端返回错误信息。

4、设置用户表中activated=true,更新用户表信息。

5、从token表中删除所有对应用户的激活token。可以调用TokenModel.DeleteAllForUser()方法来完成。

6、向客户端发送激活后的用户详细信息。

下面从cmd/api/users.go文件开始,并创建新的activateUserHandler:

File:cmd/api/users.go


package main

...


func (app *application)activateUserHandler(w http.ResponseWriter, r *http.Request)  {
    //从请求中解析激活token明文
    var input struct{
        TokenPlaintext string `json:"token"`
    }
    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }
    //校验客户端发送的token文本信息
    v := validator.New()
    if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid(){
        app.failedValidationResponse(w, r, v.Errors)
        return
    }
    //使用GetForToken()方法根据token查询用户信息。如果没找到用户,向
    //客户端返回token无效信息,使用GetForToken方法马上就会创建
    user, err := app.models.Users.GetForToken(data.ScopeActivation, input.TokenPlaintext)
    if err != nil {
        switch  {
        case errors.Is(err, data.ErrRecordNotFound):
            v.AddError("token", "invalid or expired activation token")
            app.failedValidationResponse(w, r, v.Errors)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }
    //更新用户激活状态
    user.Activated = true
    //将激活后用户信息写入数据库,并检查错误信息
    err = app.models.Users.Update(user)
    if err != nil {
        switch  {
        case errors.Is(err, data.ErrEditConflict):
            app.editConflictResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }
    //如果一切正常,删除对应用户的所有激活tokens
    err = app.models.Tokens.DeleteAllForUser(data.ScopeActivation, user.ID)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    //将激活后用户信息返回给客户端
    err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

如果你现在编译运行服务的话,会报错的因为UserModel.GetForToken()方法还没有创建,接下来就创建该方法。

UserModel.GetForToken方法

正如前面所说的,UserModel.GetForToken方法根据给定的激活token查询用户信息。如果不存在匹配用户或token过期,我们就返回ErrRecordNotFound错误。要实现该功能,需要执行以下SQL查询;

SELECT users.id, users.create_at, users.name, users.password_hash, users.activated, user.version
FROM users
INNER JOIN tokens
ON users.id = tokens.user_id
WHERE tokens.hash = $1
AND tokens.scope = $2
AND tokens.expiry > $3

该查询比我们之前的大多数SQL查询要复杂,我们简单介绍下具体功能:在这个查询中使用INNER JOIN将users表和token连接在一起。然后使用ON users.id = tokens.user_id语句表示我们要将用户id和token表中的user_id相等的数据连接起来。

您可以将INNER JOIN看作是创建一个“临时”表,其中包含来自两张表的连接数据。然后,在我们的SQL查询中,使用WHERE子句来过滤这个临时表,只留下token哈希和scope和特定占位符参数值匹配的行,并且token过期时间在客户端发送的时间之后。因为token哈希值也是一个主键,所以将始终只留下一条记录,其中包含与token哈希值相关联的用户数据(如果没有匹配的token,则根本没有记录)。

如果您不熟悉在SQL中执行join,那么这篇博客将很好地概述不同类型的连接、它们的工作方式,以及一些应该有助于您理解的示例。

如果你跟随本系列文章操作,打开internal/data/users.go文件添加GetForToken()方法,并执行上面的SQL查询:

File: internal/data/users.go


package data

...

func (m UserModel)GetForToken(tokenScope, tokenPlaintext string) (*User, error) {
    //根据客户端提供的token计算SHA-256哈希值,记住其返回的是长度为32的byte数组
    tokenHash := sha256.Sum256([]byte(tokenPlaintext))
    //设置SQL查询
    query := `
        SELECT users.id, users.create_at, users.name, users.password_hash, users.activated, user.version
        FROM users
        INNER JOIN tokens
        ON users.id = tokens.user_id
        WHERE tokens.hash = $1
        AND tokens.scope = $2
        AND tokens.expiry > $3`
    //传入参数
    args := []interface{}{tokenHash[:], tokenScope, time.Now()}

    var user User
    ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
    defer cancel()
    //执行sql查询,并读取查找结果
    err := m.DB.QueryRowContext(ctx, query, args...).Scan(
        &user.ID,
        &user.CreateAt,
        &user.Name,
        &user.Email,
        &user.Password,
        &user.Activated,
        &user.Version,
        )
    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            return nil, ErrRecordNotFound
        default:
            return nil, err
        }
    }
    //返回查找到的用户信息
    return &user, nil
}

现在代码已经写的差不多来,最后需要为PUT /v1/users/activated接口注册路由:

File:cmd/api/routes.go


package main

...

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.healthcheckHandler)

    router.HandlerFunc(http.MethodGet, "/v1/movies", app.listMoviesHandler)
    router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler)
    router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)
    router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler)
    router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler)

    router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
    //为PUT /v1/users/activated接口添加路由
    router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)

    return app.recoverPanic(app.rateLimit(router))
}

顺便说一句,我们使用PUT而不是POST来注册这个接口的原因是因为它是幂等的。

如果客户端多次发送同一个PUT /v1/users/activated请求,第一次会成功(假设token是有效的)但后面发送的都会向客户端返回错误响应(因为对应用户的激活token都被清除了)。但重要的是,在第一个请求之后,我们的应用程序状态(即数据库)没有任何变化。

基本上,客户端多次发送相同的请求不会产生应用程序状态副作用,这意味着接口是幂等的,使用PUT比POST更合适。

OK,下面重启服务测下激活接口。

首先,向PUT /v1/users/activated接口发送包含无效的激活token。客户端将接收到对应的错误信息如下所示:

$ curl -X PUT -d '{"token": "invalid"}' localhost:4000/v1/users/activated
{
        "error": {
                "token": "must be 26 bytes long"
        }
}
$  curl -X PUT -d '{"token": "ABCDEFGHIJKLMNOPQRSTUVWXYZ"}' localhost:4000/v1/users/activated
{
        "error": {
                "token": "invalid or expired activation token"
        }
}

然后使用一个从用户的欢迎邮件中读取的有效激活token。在我这,使用的是前面Mailtrap收件箱中读取到的token:7IYVRNWW2W3DXUM3S7Q3OVRAUU来激活用户faith@example.com,上一节中注册的。客户端应该得的JSON响应返回一个激活字段,确认用户已经被激活,类似如下:

curl -X PUT -d '{"token": "7IYVRNWW2W3DXUM3S7Q3OVRAUU"}' localhost:4000/v1/users/activated
{
        "user": {
                "id": 1,
                "create_at": "2022-01-03T14:10:11+08:00",
                "name": "Faith Smith",
                "email": "faith@example.com",
                "activated": true
        }
}

如果你使用上面的token再发起请求,会得到"invalid or expired activation token"错误,因为在第一次激活的时候已经将faith@example.com的激活token删除了。

$  curl -X PUT -d '{"token": "7IYVRNWW2W3DXUM3S7Q3OVRAUU"}' localhost:4000/v1/users/activated
{
        "error": {
                "token": "invalid or expired activation token"
        }
}

重要提示:在生产环境中,为了安全使用激活token来激活真实账户时,必须确保使用的是HTTPS连接而不是http。

我们去数据库中查看下用户表的变化:

$ psql $GREENLIGHT_DB_DSN
psql (13.4)
Type "help" for help.

greenlight=> select email, activated, version from users;
       email       | activated | version 
-------------------+-----------+---------
 faith@example.com | t         |       2
 alice@example.com | f         |       1
(2 rows)

和其他的用户对比,可以看到faith@example.com对应的activated=true,version字段值增加为2。

附加内容

web应用程序工作流

如果你的API是一个网站的后端,而不是一个完全独立的服务,你可以调整激活工作流,对用户更简单和更直观,同时仍然是安全的。这里有两个选择:第一个也是最健壮的选择就是让用户拷贝激活token到你的网站表单中然后使用JavaScript执行PUT /v1/users/activated请求。这种激活流程的欢迎邮件可以这么写,如下所示:

Hi,
Thanks for signing up for a Greenlight account. We're excited to have you on board!

For future reference, your user ID number is 123.

To activate your Greenlight account please visit h͟t͟t͟p͟s͟:͟/͟/͟e͟x͟a͟m͟p͟l͟e͟.͟c͟o͟m͟/͟u͟s͟e͟r͟s͟/͟a͟c͟t͟i͟v͟a͟t͟e͟ and 
enter the following code:

-------------------------- 
Y3QMGX3PJ3WLRL2YRTQGQ6KRHU
--------------------------

Please note that this code will expire in 3 days and can only be used once. 

Thanks,

The Greenlight Team

这种方法对网站而言非常简单安全,只需要提供表单将激活token通过PUT请求提交到后台,不需要用户手动执行curl命令。

注意:在邮件当中创建链接,不能依赖r.Host来构建URL,因为容易导致host请求头注入攻击,URL域名应该是硬编码的,或者在启动应用程序时作为命令行标志传入。

第二种方法:如果你不想用户复制黏贴token的话,可以让用户点击一个包含激活token的链接,转到网站的另一个页面。如下所示:

Hi,

Thanks for signing up for a Greenlight account. We're excited to have you on board! 

For future reference, your user ID number is 123.

To activate your Greenlight account please click the following link: 

h͟t͟t͟p͟s͟:͟/͟/͟e͟x͟a͟m͟p͟l͟e͟.͟c͟o͟m͟/͟u͟s͟e͟r͟s͟/͟a͟c͟t͟i͟v͟a͟t͟e͟?͟t͟o͟k͟e͟n͟=͟Y͟3͟Q͟M͟G͟X͟3͟P͟J͟3͟W͟L͟R͟L͟2͟Y͟R͟T͟Q͟G͟Q͟6͟K͟R͟H͟U͟

Please note that this link will expire in 3 days and can only be used once.

Thanks,

The Greenlight Team

跳转后的页面可以显示一个按钮类似“确认激活“,然后当用户点击确认按钮时,使用javaScript提取出URL中的激活token提交到PUT /v1/users/activated接口。

如果你使用第二种方法,还需要采取一些措施防止用户转到不同网站时,token在请求头引用中泄漏。可以使用Referrer-Policy: Origin请求头或<meta name="referrer" content="origin"> HTML标签来处理,尽管并不是所有的浏览器都支持(目前大约96%都支持)。

以上两种方法,无论邮件和激活流程在前端和用户体验方面看起来如何,后端API接口都是相同的,不需要更改。

SQL查询定时攻击

需要指出的是在UserModel.GetForToken()函数中使用的SQL查询,在理论上存在定时攻击,因为PostgreSQL在对tokens.hash = $1求值不是在常数时间内执行。

SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version FROM users
INNER JOIN tokens
ON users.id = tokens.user_id
WHERE tokens.hash = $1 --<-- 这很容易受到定时攻击
AND tokens.scope = $2
AND tokens.expiry > $3

尽管实现起来有些困难,但理论上攻击者可以向我们的PUT /v1/users/activated接口发出数千个请求,并分析平均响应时间中的微小差异,以在数据库中构建token的哈希值画像。

但是,在我们的例子中,即使定时攻击成功,它也只会从数据库中泄漏经过散列处理的token值——而不是用户实际上需要提交来激活他们的帐户的明文token。

因此,攻击者仍然需要使用暴力来找到一个26个字符的字符串,而这个字符串恰好与他们从计时攻击中发现的SHA-256哈希值相同。这是很难做到的,而且在目前的技术下是不可行的。

上一篇下一篇

猜你喜欢

热点阅读