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

تولید توکن‌های احراز هویت

در این فصل بر روی نوشتن کد برای یک endpoint جدید POST/v1/tokens/authentication تمرکز خواهیم کرد که به client اجازه می‌دهد اطلاعات ورود خود (آدرس ایمیل و رمز عبور) را با یک authentication token stateful مبادله کند.

در سطح بالا، فرآیند مبادله اطلاعات ورود کاربر با یک authentication token به این صورت عمل خواهد کرد:

  1. client یک درخواست JSON به endpoint جدید POST/v1/tokens/authentication ارسال می‌کند که شامل اطلاعات ورود او (ایمیل و رمز عبور) است.
  2. ما رکورد کاربر را بر اساس ایمیل جستجو کرده و بررسی می‌کنیم که رمز عبور ارائه شده برای آن کاربر صحیح باشد. اگر صحیح نبود، یک پاسخ خطا ارسال می‌کنیم.
  3. اگر رمز عبور صحیح بود، از متد app.models.Tokens.New() برای تولید یک token با زمان انقضای ۲۴ ساعت و scope "authentication" استفاده خواهیم کرد.
  4. این authentication token را در بدنه پاسخ JSON به client ارسال می‌کنیم.

بیایید در فایل internal/data/tokens.go خود شروع کنیم.

نیاز داریم این فایل را به روز کنیم تا یک scope جدید "authentication" تعریف کنیم و برخی struct tags را اضافه کنیم تا نحوه نمایش struct Token هنگام رمزگذاری به JSON را سفارشی کنیم. به این صورت:

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

...

const (
    ScopeActivation     = "activation"
    ScopeAuthentication = "authentication" // Include a new authentication scope.
)

// Add struct tags to control how the struct appears when encoded to JSON.
type Token struct {
    Plaintext string    `json:"token"`
    Hash      []byte    `json:"-"`
    UserID    int64     `json:"-"`
    Expiry    time.Time `json:"expiry"`
    Scope     string    `json:"-"`
}

...

این struct tagهای جدید به این معنی هستند که فقط فیلدهای Plaintext و Expiry هنگام رمزگذاری یک struct Token گنجانده خواهند شد — تمام فیلدهای دیگر حذف می‌شوند. همچنین فیلد Plaintext را به "token" تغییر نام می‌دهیم، فقط به این دلیل که برای client‌ها نام معنادارتری از ‘plaintext’ است.

در مجموع، این به این معناست که هنگام رمزگذاری یک struct Token به JSON نتیجه شبیه به این خواهد بود:

{
    "token": "X3ASTT2CDAN66BACKSCI4SU7SI",
    "expiry": "2021-01-18T13:00:25.648511827+01:00"
}

ساخت endpoint

حال بیایید به اصل مطلب این فصل بپردازیم و تمام کد مربوط به endpoint جدید POST/v1/tokens/authentication را تنظیم کنیم. وقتی کار تمام شود، مسیرهای API ما به این شکل خواهند بود:

متد الگوی URL Handler عملیات
GET /v1/healthcheck healthcheckHandler نمایش اطلاعات برنامه
GET /v1/movies listMoviesHandler نمایش جزئیات تمام فیلم‌ها
POST /v1/movies createMovieHandler ایجاد یک فیلم جدید
GET /v1/movies/:id showMovieHandler نمایش جزئیات یک فیلم خاص
PATCH /v1/movies/:id updateMovieHandler به‌روزرسانی جزئیات یک فیلم خاص
DELETE /v1/movies/:id deleteMovieHandler حذف یک فیلم خاص
POST /v1/users registerUserHandler ثبت‌نام یک کاربر جدید
PUT /v1/users/activated activateUserHandler فعال‌سازی یک کاربر خاص
POST /v1/tokens/authentication createAuthenticationTokenHandler تولید یک authentication token جدید

اگر می‌خواهید همراه با ما پیش بروید، یک فایل جدید cmd/api/tokens.go ایجاد کنید:

$ touch cmd/api/tokens.go

و در این فایل جدید کد مربوط به createAuthenticationTokenHandler را اضافه خواهیم کرد.

اساساً، می‌خواهیم این handler ایمیل و رمز عبور کاربر را با یک authentication token مبادله کند، به این صورت:

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

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

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

func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) {
    // Parse the email and password from the request body.
    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
    }

    // Validate the email and password provided by the client.
    v := validator.New()

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

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

    // Lookup the user record based on the email address. If no matching user was
    // found, then we call the app.invalidCredentialsResponse() helper to send a 401
    // Unauthorized response to the client (we will create this helper in a moment).
    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
    }

    // Check if the provided password matches the actual password for the user.
    match, err := user.Password.Matches(input.Password)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // If the passwords don't match, then we call the app.invalidCredentialsResponse()
    // helper again and return.
    if !match {
        app.invalidCredentialsResponse(w, r)
        return
    }

    // Otherwise, if the password is correct, we generate a new token with a 24-hour 
    // expiry time and the scope 'authentication'.
    token, err := app.models.Tokens.New(user.ID, 24*time.Hour, data.ScopeAuthentication)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // Encode the token to JSON and send it in the response along with a 201 Created
    // status code.
    err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

بیایید به سرعت helper invalidCredentialsResponse() را نیز در فایل cmd/api/errors.go خود ایجاد کنیم:

فایل: 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)
}

سپس در نهایت، باید endpoint POST /v1/tokens/authentication را در مسیرهای برنامه خود قرار دهیم.

فایل: 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)

    // Add the route for the POST /v1/tokens/authentication endpoint.
    router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler)

    return app.recoverPanic(app.rateLimit(router))
}

با تمام این‌ها تمام شده، اکنون باید بتوانیم یک authentication token تولید کنیم.

یک درخواست به endpoint جدید POST /v1/tokens/authentication با یک آدرس ایمیل و رمز عبور معتبر برای یکی از کاربرانی که قبلاً ایجاد کرده‌اید ارسال کنید. باید یک پاسخ 201 Created و یک بدنه JSON حاوی یک authentication token دریافت کنید، مشابه این:

$ 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: Fri, 16 Apr 2021 09:03:36 GMT
Content-Length: 125

{
    "authentication_token": {
        "token": "IEYZQUBEMPPAKPOAWTPV6YJ6RM",
        "expiry": "2021-04-17T11:03:36.767078518+02: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: Fri, 16 Apr 2021 09:54:01 GMT
Content-Length: 51

{
    "error": "invalid authentication credentials"
}

قبل از ادامه، بیایید به سرعت جدول tokens در پایگاه داده PostgreSQL خود نگاهی بیندازیم تا بررسی کنیم که authentication token ایجاد شده است.

$ psql $GREENLIGHT_DB_DSN
Password for user greenlight: 
psql (15.4 (Ubuntu 15.4-1.pgdg22.04+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.

greenlight=> SELECT * FROM tokens WHERE scope = 'authentication';
\x4390d2ff4af7346dd4238ffccb8a5b18e8c3af9aa8cf57852895ad0f8ee2c50d |       1 | 2021-04-17 11:03:37+02 | authentication

عالی به نظر می‌رسد. می‌توانیم ببینیم که token مربوط به کاربر با ID 1 است (که اگر همراه ما پیش رفته باشید کاربر alice@example.com خواهد بود) و scope و زمان انقضای صحیح را دارد.


اطلاعات تکمیلی

هدر Authorization

گاهی اوقات ممکن است با APIها یا آموزش‌هایی مواجه شوید که authentication token‌ها را به جای بدنه پاسخ مانند آنچه در این فصل انجام می‌دهیم، در هدر Authorization به client ارسال می‌کنند.

شما می‌توانید این کار را انجام دهید و در بیشتر موارد احتمالاً به خوبی کار خواهد کرد. اما مهم است که آگاه باشید که نقض عمدی مشخصات HTTP را انجام می‌دهید: Authorization یک هدر درخواست است، نه یک هدر پاسخ.