Let's Go Further پیوست‌ها › احراز هویت با توکن‌های وب JSON
قبلی · فهرست مطالب · بعدی
فصل ۲۱.۳.

احراز هویت با توکن‌های وب JSON

در این پیوست، فرآیند احراز هویت API خود را به استفاده از توکن‌های وب JSON (JWTs) تغییر خواهیم داد.

همانطور که قبلاً در کتاب به طور خلاصه توضیح دادیم، JWTs نوعی توکن بی‌状态 هستند. آن‌ها مجموعه‌ای از ادعاهایی (claims) را شامل می‌شوند که امضا شده‌اند (با استفاده از الگوریتم امضای متقارن یا نامتقارن) و سپس با استفاده از base64 رمزگذاری می‌شوند. در مورد ما، از JWTs برای حمل ادعای subject حاوی شناسه کاربر احراز هویت شده استفاده خواهیم کرد.

چندین package مختلف وجود دارد که کار با JWTs را در Go نسبتاً آسان می‌کنند. در بیشتر موارد، package pascaldekloe/jwt انتخاب خوبی است — دارای API ساده و شفافی است و به طور پیش‌فرض برای جلوگیری از چند آسیب‌پذیری امنیتی اصلی JWT طراحی شده است.

اگر می‌خواهید همراه با ما پیش بروید، لطفاً آن را به این صورت نصب کنید:

$ go get github.com/pascaldekloe/jwt@v1

یکی از اولین مواردی که هنگام استفاده از JWTs باید در نظر بگیرید، انتخاب الگوریتم امضا است.

اگر قرار است JWT شما توسط یک application متفاوت نسبت به application سازنده آن مصرف شود، باید معمولاً از الگوریتم کلید نامتقارن مانند ECDSA یا RSA استفاده کنید. application «سازنده» از کلید خصوصی خود برای امضای JWT استفاده می‌کند و application «مصرف‌کننده» از کلید عمومی متناظر برای تأیید امضا استفاده می‌کند.

اما اگر قرار است JWT شما توسط همان application سازنده مصرف شود، انتخاب مناسب یک الگوریتم کلید متقارن (ساده‌تر و سریع‌تر) مانند HMAC-SHA256 با یک کلید تصادفی محرمانه است. این همان چیزی است که ما برای API خود استفاده خواهیم کرد.

بنابراین، برای راه‌اندازی احراز هویت با JWTs، ابتدا باید یک کلید محرمانه برای امضای JWTs به فایل .envrc خود اضافه کنید. به عنوان مثال:

فایل: .envrc
export GREENLIGHT_DB_DSN=postgres://greenlight:pa55word@localhost/greenlight
export JWT_SECRET=pei3einoh0Beem6uM6Ungohn2heiv5lah1ael4joopie5JaigeikoozaoTew2Eh6

و سپس باید فایل Makefile خود را به روز کنید تا کلید محرمانه را به عنوان یک flag خط فرمان هنگام راه‌اندازی application پاس دهید، به این صورت:

فایل: Makefile
...

# ==================================================================================== #
# DEVELOPMENT
# ==================================================================================== #

## run/api: run the cmd/api application
.PHONY: run/api
run/api:
	@go run ./cmd/api -db-dsn=${GREENLIGHT_DB_DSN} -jwt-secret=${JWT_SECRET}

...

در مرحله بعد باید فایل cmd/api/main.go را ویرایش کنید تا secret JWT را از flag خط فرمان در ساختار config تجزیه کند:

فایل: cmd/api/main.go
package main

...

type config struct {
    port int
    env  string
    db   struct {
        dsn          string
        maxOpenConns int
        maxIdleConns int
        maxIdleTime  time.Duration
    }
    limiter struct {
        enabled bool
        rps     float64
        burst   int
    }
    smtp struct {
        host     string
        port     int
        username string
        password string
        sender   string
    }
    cors struct {
        trustedOrigins []string
    }
    jwt struct {
        secret string // Add a new field to store the JWT signing secret.
    }
}

...

func main() {
    var cfg config

    ...

    // Parse the JWT signing secret from the command-line flag. Notice that we leave the
    // default value as the empty string if no flag is provided.
    flag.StringVar(&cfg.jwt.secret, "jwt-secret", "", "JWT secret")

    displayVersion := flag.Bool("version", false, "Display version and exit")

    flag.Parse()

    ...
}

...

و پس از اتمام این کار، می‌توانید createAuthenticationTokenHandler() را به گونه‌ای تغییر دهید که به جای توکن حالت‌دار، یک JWT تولید و ارسال کند. به این صورت:

فایل: cmd/api/tokens.go
package main

import (
    "errors"
    "net/http"
    "strconv" // New import
    "time"

    "greenlight.alexedwards.net/internal/data"
    "greenlight.alexedwards.net/internal/validator"

    "github.com/pascaldekloe/jwt" // New import
)

func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) {
    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
    }

    v := validator.New()

    data.ValidateEmail(v, input.Email)
    data.ValidatePasswordPlaintext(v, input.Password)

    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    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
    }

    if !match {
        app.invalidCredentialsResponse(w, r)
        return
    }

    // Create a JWT claims struct containing the user ID as the subject, with an issued
    // time of now and validity window of the next 24 hours. We also set the issuer and
    // audience to a unique identifier for our application.
    var claims jwt.Claims
    claims.Subject = strconv.FormatInt(user.ID, 10)
    claims.Issued = jwt.NewNumericTime(time.Now())
    claims.NotBefore = jwt.NewNumericTime(time.Now())
    claims.Expires = jwt.NewNumericTime(time.Now().Add(24 * time.Hour))
    claims.Issuer = "greenlight.alexedwards.net"
    claims.Audiences = []string{"greenlight.alexedwards.net"}

    // Sign the JWT claims using the HMAC-SHA256 algorithm and the secret key from the
    // application config. This returns a []byte slice containing the JWT as a base64-
    // encoded string.
    jwtBytes, err := claims.HMACSign(jwt.HS256, []byte(app.config.jwt.secret))
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // Convert the []byte slice to a string and return it in a JSON response.
    err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": string(jwtBytes)}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

...

برای ادامه کار، dependency جدید github.com/pascaldekloe/jwt را به صورت vendor درآورید و API را به این صورت اجرا کنید:

$ make vendor
$ make run/api

سپس وقتی درخواستی به endpoint POST /v1/tokens/authentication با یک آدرس ایمیل و رمز عبور معتبر ارسال می‌کنید، اکنون باید پاسخی حاوی JWT مانند این دریافت کنید (شکستن خطوط برای خوانایی اضافه شده است):

$ curl -X POST -d ''{"email": "faith@example.com", "password": "pa55word"}'' localhost:4000/v1/tokens/authentication
{
    "authentication_token": "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJncmVlbmxpZ2h0LmFsZXhlZHdhcm
    RzLm5ldCIsInN1YiI6IjciLCJhdWQiOlsiZ3JlZW5saWdodC5hbGV4ZWR3YXJkcy5uZXQiXSwiZXhwIjoxNj
    E4OTM4MjY0LjgxOTIwNSwibmJmIjoxNjE4ODUxODY0LjgxOTIwNSwiaWF0IjoxNjE4ODUxODY0LjgxOTIwND
    h9.zNK1bJPl5rlr_YvjyOXuimJwaC3KgPqmW2M1u5RvgeA"
}

اگر کنجکاو هستید، می‌توانید داده‌های رمزگذاری‌شده base64 JWT را رمزگشایی کنید. باید ببینید که محتوای ادعاها با اطلاعاتی که انتظار دارید مطابقت دارد، مشابه این:

{"alg":"HS256"}{"iss":"greenlight.alexedwards.net","sub":"7","aud":["greenlight.alexedwards.net"],
"exp":1618938264.819205,"nbf":1618851864.819205,"iat":1618851864.8192048}...

در مرحله بعد باید middleware authenticate() را به روز کنید تا JWTها را در هدر Authorization: Bearer <jwt> بپذیرد، JWT را تأیید کند و شناسه کاربر را از ادعای subject استخراج کند.

وقتی می‌گوییم «JWT را تأیید کن»، در واقع چهار مورد زیر را منظور داریم:

برای ادامه کار، middleware authenticate() را به صورت زیر به روز کنید:

فایل: cmd/api/middleware.go
package main

import (
    "errors"
    "expvar"
    "fmt"
    "net/http"
    "strconv"
    "strings"
    "sync"
    "time"

    "greenlight.alexedwards.net/internal/data"

    "github.com/pascaldekloe/jwt" // New import
    "github.com/tomasen/realip"
    "golang.org/x/time/rate"
)

...

func (app *application) authenticate(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Add("Vary", "Authorization")

        authorizationHeader := r.Header.Get("Authorization")

        if authorizationHeader == "" {
            r = app.contextSetUser(r, data.AnonymousUser)
            next.ServeHTTP(w, r)
            return
        }

        headerParts := strings.Split(authorizationHeader, " ")
        if len(headerParts) != 2 || headerParts[0] != "Bearer" {
            app.invalidAuthenticationTokenResponse(w, r)
            return
        }

        token := headerParts[1]

        // Parse the JWT and extract the claims. This will return an error if the JWT 
        // content doesn't match the signature (i.e. the token has been tampered with)
        // or the algorithm isn't valid.
        claims, err := jwt.HMACCheck([]byte(token), []byte(app.config.jwt.secret))
        if err != nil {
            app.invalidAuthenticationTokenResponse(w, r)
            return
        }

        // Check if the JWT is still valid at this moment in time.
        if !claims.Valid(time.Now()) {
            app.invalidAuthenticationTokenResponse(w, r)
            return
        }

        // Check that the issuer is our application.
        if claims.Issuer != "greenlight.alexedwards.net" {
            app.invalidAuthenticationTokenResponse(w, r)
            return
        }

        // Check that our application is in the expected audiences for the JWT.
        if !claims.AcceptAudience("greenlight.alexedwards.net") {
            app.invalidAuthenticationTokenResponse(w, r)
            return
        }

        // At this point, we know that the JWT is all OK and we can trust the data in 
        // it. We extract the user ID from the claims subject and convert it from a 
        // string into an int64.
        userID, err := strconv.ParseInt(claims.Subject, 10, 64)
        if err != nil {
            app.serverErrorResponse(w, r, err)
            return
        }

        // Lookup the user record from the database.
        user, err := app.models.Users.Get(userID)
        if err != nil {
            switch {
            case errors.Is(err, data.ErrRecordNotFound):
                app.invalidAuthenticationTokenResponse(w, r)
            default:
                app.serverErrorResponse(w, r, err)
            }
            return
        }

        // Add the user record to the request context and continue as normal.
        r = app.contextSetUser(r, user)

        next.ServeHTTP(w, r)
    })
}

در نهایت، برای راه‌اندازی این قابلیت، باید یک method جدید UserModel.Get() ایجاد کنید تا جزئیات کاربر را بر اساس شناسه آن‌ها از پایگاه داده بازیابی کند.

فایل: internal/data/users.go
package data

...

func (m UserModel) Get(id int64) (*User, error) {
    query := `
        SELECT id, created_at, name, email, password_hash, activated, version
        FROM users
        WHERE id = $1`

    var user User

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    err := m.DB.QueryRowContext(ctx, query, id).Scan(
        &user.ID,
        &user.CreatedAt,
        &user.Name,
        &user.Email,
        &user.Password.hash,
        &user.Activated,
        &user.Version,
    )

    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            return nil, ErrRecordNotFound
        default:
            return nil, err
        }
    }

    return &user, nil
}

اکنون باید بتوانید درخواستی به یکی از endpoint‌های محافظت‌شده ارسال کنید و تنها در صورتی موفقیت‌آمیز خواهد بود که درخواست شما حاوی یک JWT معتبر در هدر Authorization باشد. به عنوان مثال:

$ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJncmVlbmxpZ..." localhost:4000/v1/movies/2
{
    "movie": {
        "id": 2,
        "title": "Black Panther",
        "year": 2018,
        "runtime": "134 mins",
        "genres": [
            "sci-fi",
            "action",
            "adventure"
        ],
        "version": 2
    }
}

$ curl -H "Authorization: Bearer INVALID" localhost:4000/v1/movies/2
{
    "error": "invalid or missing authentication token"
}

اگر قصد دارید سیستمی را با استفاده از JWTs به مرحله production برسانید، همچنین خواندن و درک کامل دو مقاله زیر را توصیه می‌کنم: