【Go Web 開發】認證請求

上一篇文章實現了客戶端通過發送認證信息獲得身份驗證 token,那麼讓我們看看如何使用該 token 來驗證用戶,實現服務端準確地知道請求來自哪個用戶。

本質上,一旦客戶端有了一個認證 token,後續訪問 API 服務時後端服務將從客戶端 Authorization 請求頭中獲取 token,像這樣:

 Authorization: Bearer IEYZQUBEMPPAKPOAWTPV6YJ6RM

當我們收到這些帶認證 token 請求時,將使用一個新的 authenticate() 中間件方法來執行以下邏輯:

創建匿名用戶

我們從上面所述的最後一點開始,先在 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):

爲了幫助解決這個問題,我們創建一個新的 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)
    })
}

這裏有很多代碼,爲了說明清楚,我們快速重申下中間件中的操作:

下面在 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"
}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/RRk8ngOMunNMzcUghE60Ow