美文网首页go
【Go Web开发】用户激活

【Go Web开发】用户激活

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

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

    • 根据token查询用户
    • 根据用户查询所有tokens

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

    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哈希值相同。这是很难做到的,而且在目前的技术下是不可行的。

    相关文章

      网友评论

        本文标题:【Go Web开发】用户激活

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