上一篇文章我们介绍了API服务的认证方法,本节将实现POST /v1/tokens/authentication接口,该接口允许客户端通过发送凭证信息(email地址和密码)获取认证token。
提示:为了简洁起见,在接下来的内容中,我们不再重复“有状态的身份验证token”这个词,而是将其简单地称为用户身份认证token。
下面说明下处理用户发送的凭证信息,并获取认证token的大概流程:
1、客户端发送JSON请求到POST /v1/tokens/authentcation接口,请求体中包含用户认证信息(email地址和密码)。
2、服务端根据客户端发送的email地址,查询数据库对应用户信息,校验密码是否正确。如果校验失败,返回错误响应。
3、如果密码正确,调用app.models.Token.New()方法生成一个过期时间为24小时的token,并将scope值设置为“authentication”。
4、将认证token以JSON响应格式返回给客户端。
下面从internal/data/tokens.go文件开始。需要更新该文件,定义新的常量"authentication",并添加结构体标签便于JSON序列化,如下所示:
File: internal/data/tokens.go
package data
...
const (
ScopeActivation = "activation"
ScopeAuthentication = "authentication" //增加一个新的认证标识
)
// 定义Token结构体接收token数据。包括token字符串和哈希值,以及用户ID,过期时间和范围。
type Token struct {
Plaintext string `json:"token"`
Hash []byte `json:"-"`
UserID int64 `json:"-"`
Expiry time.Time `json:"expiry"`
Scope string `json:"-"`
}
...
这些结构体字段标签只有Plaintext和expiry字段需要序列化到Token结构体中,其他字段可以忽略。我们还将Plaintext字段重命名为“token”,因为这对客户端来说比plaintext更直观。总之,当我们将一个Token结构体编码为JSON时,结果将类似于:
{
"token": "X3ASTT2CDAN66BACKSCI4SU7SI",
"expiry": "2021-01-18T13:00:25.648511827+01:00"
}
接口创建
现在我们进入本节重点,实现POST/v1/tokens/authentication接口到代码。当接口完成后,我们的API路由看起来像这样:
Method | URL Pattern | Handler | 动作 |
---|---|---|---|
POST | /v1/tokens/authentication | createAuthenticationTokenHandler | 生成一个新的认证token |
如果你跟随本系列文章操作,请创建新的cmd/api/tokens.go文件:
$ touch cmd/api/tokens.go
在这个文件当中,我们需要创建createAuthenticationTokenHandler处理程序。本质上这个接口处理程序的目标是根据收到的用户email地址和密码生成一个认证tokens返回给客户端。
File:cmd/api/tokens.go
package main
import (
"errors"
"greenlight.alexedwards.net/internal/data"
"greenlight.alexedwards.net/internal/validator"
"net/http"
"time"
)
func (app *application)createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) {
//解析请求体中的email和password
var input struct{
Email string `json:"email"`
Password string `json:"password"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
//校验email和password
v := validator.New()
data.ValidateEmail(v, input.Email)
data.ValidatePasswordPlaintext(v, input.Password)
if !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
//根据email地址查询用户,如果没有匹配的用户,调用app.invalidCredentialsResponse()帮助函数
//发送401 Unauthorized response(稍后创建)给客户端。
user, err := app.models.Users.GetByEmail(input.Email)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.invalidCredentialsResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
//检查密码是否和数据库中用户密码匹配
match, err := user.Password.Matches(input.Password)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
//如果密码不匹配,再次调用app.invalidCredentialsResponse()并返回
if !match {
app.invalidCredentialsResponse(w, r)
return
}
//否则,密码正确的话,生成一个24小时内有效的认证token,scope为"authentication"
token, err := app.models.Tokens.New(user.ID, 24 * time.Hour, data.ScopeAuthentication)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
//将token编码到JSON中返回201 Created给客户端
err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
下面在cmd/api/errors.go文件中快速创建invalidCredentialsResponse()帮助函数。
File: cmd/api/errors.go
package main
...
func (app *application)invalidCredentialsResponse(w http.ResponseWriter, r *http.Request) {
message := "invalid authentication credentials"
app.errorResponse(w, r, http.StatusUnauthorized, message)
}
最后,我们需要为POST /v1/tokens/authentication接口添加路由:
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)
router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
//为POST /v1/tokens/authentication接口添加路由
router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler)
return app.recoverPanic(app.rateLimit(router))
}
完成以上代码后,API应用应该可以生成认证token了。重启服务然后向POST /v1/tokens/authentication接口发起请求,带上我们之前创建的某个用户有效email和password。你应该可以得到201 Created响应以及一个包含认证token的JSON对象。如下所示:
$ BODY='{"email": "alice@example.com", "password": "pa55word"}'
$ curl -i -d "$BODY" localhost:4000/v1/tokens/authentication
HTTP/1.1 201 Created
Content-Type: application/json
Date: Wed, 05 Jan 2022 05:16:14 GMT
Content-Length: 122
{
"authentication_token": {
"token": "Q7UMDFON5QG2K5UHWALP23LEBM",
"expiry": "2022-01-06T13:16:14.696128+08:00"
}
}
相反,如果您尝试使用格式正确但未知的邮件地址或不正确的密码发出请求,则应该得到错误响应。例如:
$ BODY='{"email": "alice@example.com", "password": "wrong pa55word"}'
$ curl -i -d "$BODY" localhost:4000/v1/tokens/authentication
HTTP/1.1 401 Unauthorized
Content-Type: application/json
Date: Wed, 05 Jan 2022 05:18:32 GMT
Content-Length: 51
{
"error": "invalid authentication credentials"
}
在继续下一步之前,我们快速检查下PostgreSQL数据库中token表,看看认证token是否写入数据库。
$ psql $GREENLIGHT_DB_DSN
psql (13.4)
Type "help" for help.
greenlight=> select * from tokens where scope='authentication';
hash | user_id | expiry | scope
--------------------------------------------------------------------+---------+------------------------+----------------
\xf0e7940395bf219d3cf2f5f84adbda807063ff97543af9f56ae26d0611efa03b | 2 | 2022-01-06 13:16:15+08 | authentication
(1 row)
非常好,我们看到token与id为2的用户关联,也就是邮箱alice@example.com对应的用户。包含正确的scope和expiry值。
附件内容
授权请求头(Authorization header)
偶尔您可能会遇到其他API服务或教程,其中身份验证token是在Authorization请求头中返回给客户端的,而不是像我们在本文中那样在响应体中返回。你可以这样做,在大多数情况下,它可能会正常地工作。但是一定要意识到您故意违反了HTTP规范:authorization是请求头,而不是响应头。
网友评论