【Go Web开发】认证请求

上一篇文章实现了客户端通过发送认证信息获得身份验证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 <token>"格式。
        //我们试着把它分成对应的组成部分,如果请求头格式不正确,
        //使用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": "alice@example.com", "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"
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354

推荐阅读更多精彩内容