上一篇文章实现了客户端通过发送认证信息获得身份验证token,那么让我们看看如何使用该token来验证用户,实现服务端准确地知道请求来自哪个用户。
本质上,一旦客户端有了一个认证token,后续访问API服务时后端服务将从客户端Authorization请求头中获取token,像这样:
Authorization: Bearer IEYZQUBEMPPAKPOAWTPV6YJ6RM
当我们收到这些带认证token请求时,将使用一个新的authenticate()中间件方法来执行以下逻辑:
- 如果认证token无效,我们将向客户端返回401 Unauthorized响应以及一个错误消息,让调用者知道他们的token是无效的。
- 如果认证token有效,查询用户详细信息,然后将用户详细信息添加到请求上下文当中。
- 如果没有提供Authorization请求头,API服务会添加一个匿名用户信息到请求上下文中。
创建匿名用户
我们从上面所述的最后一点开始,先在internal/data/user.go中定义一个匿名用户,如下所示:
File:internal/data/user.go
package main
...
var (
ErrDuplicateEmail = errors.New("duplicate email")
AnonymousUser = &User{} //声明一个匿名用户变量
)
type User struct {
ID int64 `json:"id"`
CreateAt time.Time `json:"create_at"`
Name string `json:"name"`
Email string `json:"email"`
Password password `json:"-"`
Activated bool `json:"activated"`
Version int `json:"-"`
}
//检查用户是否为匿名用户
func (u *User)IsAnonymous() bool {
return u == AnonymousUser
}
...
这里我们创建一个新的AnonymousUser变量,存放指向一个User结构体指针表示用户没有ID、name、email或password且未激活。
我们还为User结构体实现了一个IsAnonymous()方法,因此只要是User实例就可以判断是否为AnonymousUser实例,例如:
data.AnonymousUser.IsAnonymous() // → 返回 true
otherUser := &data.User{}
otherUser.IsAnonymous() // → 返回 false
读写请求上下文
在我们开始创建authenticate()中间件之前,另一个设置步骤涉及到在请求上下文中存储用户详细信息。先大概介绍下请求上下文(request context):
- 应用程序处理的每个http.Request都内置了一个context.Context实例,我们可以在请求生命周期内存储任意key/value到这个上下文中。在本文例子中我们将存储包含用户信息的User结构体实例到上下文。
- 任何存储在请求上下文中的值都是interface{}类型。这意味着从上下文中读取到的值都需要断言为值原来的类型,才能使用。
- 为请求上下文键使用自定义类型是一种很好的实践。这有助于防止您的代码与使用请求上下文存储信息的任何第三方包之间的命名冲突。
为了帮助解决这个问题,我们创建一个新的cmd/api/context.go文件,其中包含了一些辅助方法,用于在请求上下文中读写User结构体。
如果你跟随本书操作,请创建一个新文件:
touch cmd/api/context.go
然后添加以下代码:
File:cmd/api/context.go
package main
import (
"context"
"greenlight.alexedwards.net/internal/data"
"net/http"
)
//自定义contextKey类型
type contextKey string
//将字符串"user"转为contextKey类型,然后赋值给userContextKey常量。
//我们将使用这个常量来从请求上下文中读写用户信息
const userContextKey = contextKey("user")
//contextSetUser()方法返回一个包含User结构体的请求实例。注意使用userContextKey常量
func (app *application)contextSetUser(r *http.Request, user *data.User) *http.Request {
ctx := context.WithValue(r.Context(), userContextKey, user)
return r.WithContext(ctx)
}
//contextGetUser()方法从请求上下文中读取User结构体。从http请求中读取用户信息的时候
//会用到这个方法,如果用户不存在将返回"unexpect"错误。
func (app *application)contextGetUser(r *http.Request) *data.User {
user, ok := r.Context().Value(userContextKey).(*data.User)
if !ok {
panic("missing user value in request context")
}
return user
}
创建认证中间件
既然已经准备好了这些东西,我们就可以开始处理authenticate()中间件了。
打开cmd/api/middleware.go文件,添加以下代码:
File: cmd/api/middle.go
package main
...
func (app *application)authenticate(next http.Handler) http.Handler {
//添加"Vary: Authorization"响应头。表示缓存的响应根据Authorization请求头变化
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Authorization")
//读取Authorization请求头值,如果没找到会返回""。
authorizationHeader := r.Header.Get("Authorization")
//如果没有设置Authorization请求头,使用contextSetUser()帮助函数添加一个匿名用户AnonymousUser
//到请求上下文中。然后调用next handler并直接返回。
if authorizationHeader == "" {
r = app.contextSetUser(r, data.AnonymousUser)
next.ServeHTTP(w, r)
return
}
//否则,我们希望Authorization请求头的值以"Bearer "格式。
//我们试着把它分成对应的组成部分,如果请求头格式不正确,
//使用invalidAuthenticationTokenResponse()帮助函数返回401 Unauthorized
headerParts := strings.Split(authorizationHeader, " ")
if len(headerParts) != 2 || headerParts[0] != "Bearer" {
app.invalidAuthenticationTokenResponse(w, r)
return
}
//提取认证token
token := headerParts[1]
//校验token的格式
v := validator.New()
//如果格式不正确,使用invalidAuthenticationTokenResponse()帮助函数返回错误响应
//而不是使用failedValidationResponse()
if data.ValidateTokenPlaintext(v, token); !v.Valid() {
app.invalidAuthenticationTokenResponse(w, r)
return
}
//根据认证token查询数据库中对应用户,如果未找到用户信息再次调用invalidAuthenticationTokenResponse()
//注意:使用ScopeAuthentiaction常量查询
user, err := app.models.Users.GetForToken(data.ScopeAuthentication, token)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.invalidAuthenticationTokenResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
//调用contextSetUser()帮助函数,添加用户信息到请求上下文中
r = app.contextSetUser(r, user)
//调用next handler
next.ServeHTTP(w, r)
})
}
这里有很多代码,为了说明清楚,我们快速重申下中间件中的操作:
- 如果Authorization请求头中提供了有效的认证token的话,将User结构体包含的用户信息存储到请求上下文中。
- 如果没有提供Authorization请求头的话,将在请求头中添加匿名用户AnonymousUser到请求上下文。
- 如果提供了Authorization请求头,但格式不正确或包含无效值,将使用invalidAuthenticationTokenResponse()帮助函数返回401 Unauthorized响应给客户端。
下面在cmd/api/errors.go文件中创建帮助函数:
File: cmd/api/errors.go
package main
...
func (app *application)invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) {
w.Header().Set("www-Authenticate", "Bearer")
message := "invalid or missing authentication token"
app.errorResponse(w, r, http.StatusUnauthorized, message)
}
注意:这里使用www - authenticate: bearer请求头提醒客户端,我们希望他们使用一个提供令牌进行身份验证。
最后,我们需要将authenticate()中间件添加到handler处理链中。我们需要将这个中间件用在所有的请求中,在panic recovery和限流中间件后面,在路由之前。
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)
router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler)
//在所有请求中使用authenticate()中间件
return app.recoverPanic(app.rateLimit(app.authenticate(router)))
}
功能演示
我们先发起一个没带Authorization的请求测试下。服务端authenticate()中间件将添加AnonymousUser到请求上下文中,请求将正常处理,如下所示:
$ curl localhost:4000/v1/healthcheck
{
"status": "available",
"system_info": {
"environment": "development",
"version": "1.0.0"
}
}
下面使用一个有效的认证token来发起相同的请求。这一次,相关的用户详细信息应该会添加到请求上下文中,我们可以再次获得成功的响应。例如:
$ curl -d '{"email": "[email protected]", "password": "pa55word"}' localhost:4000/v1/tokens/authentication
{
"authentication_token": {
"token": "GVK72GDNDKFDZUVDGLFX4UVB7I",
"expiry": "2022-01-06T20:17:07.444229+08:00"
}
}
$ curl -H "Authorization: Bearer GVK72GDNDKFDZUVDGLFX4UVB7I" localhost:4000/v1/healthcheck
{
"status": "available",
"system_info": {
"environment": "development",
"version": "1.0.0"
}
}
提示:如果在这里得到错误响应,请确保在第二个请求中使用了来自第一个请求的正确身份验证token。
相反,如果发送一些包含无效的认证token请求,或Authorization请求头格式不正确。这些情况都会得到401 Unauthorized响应,如下所示:
$ curl -i -H "Authorization: Bearer XXXXXXXXXXXXXXXXXXXXXXXXXX" localhost:4000/v1/healthcheck
HTTP/1.1 401 Unauthorized
Content-Type: application/json
Vary: Authorization
Www-Authenticate: Bearer
Date: Wed, 05 Jan 2022 12:21:20 GMT
Content-Length: 56
{
"error": "invalid or missing authentication token"
}
$ curl -i -H "Authorization: INVALID" localhost:4000/v1/healthcheck
HTTP/1.1 401 Unauthorized
Content-Type: application/json
Vary: Authorization
Www-Authenticate: Bearer
Date: Wed, 05 Jan 2022 12:23:08 GMT
Content-Length: 56
{
"error": "invalid or missing authentication token"
}