【Go Web开发】用户激活
上一篇文章我们实现了向用户发送激活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哈希值相同。这是很难做到的,而且在目前的技术下是不可行的。