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

فعال‌سازی کاربر

در این فصل قسمتی از فرآیند فعال‌سازی را بررسی خواهیم کرد که در آن واقعاً کاربر را فعال می‌کنیم. اما قبل از نوشتن هر کدی، می‌خواهم به طور خلاصه درباره رابطه بین کاربران و token‌ها در سیستم‌مان صحبت کنم.

آنچه داریم در اصطلاح پایگاه‌های داده رابطه‌ای به عنوان رابطه یک به چند شناخته می‌شود — جایی که یک کاربر ممکن است token‌های زیادی داشته باشد، اما یک token فقط می‌تواند متعلق به یک کاربر باشد.

وقتی چنین رابطه یک به چندی دارید، ممکن است بخواهید query‌ها را از دو طرف مختلف روی رابطه اجرا کنید. در مورد ما، برای مثال، ممکن است بخواهیم:

برای پیاده‌سازی این query‌ها در کدتان، یک رویکرد تمیز و واضح این است که مدل‌های پایگاه‌داده خود را با اضافه کردن چند method اضافی به‌روزرسانی کنید، مانند این:

UserModel.GetForToken(token)   → Retrieve the user associated with a token
TokenModel.GetAllForUser(user) → Retrieve all tokens associated with a user

نکته خوب این رویکرد این است که entity‌های برگشتی با مسئولیت اصلی هر model هم‌خوانی دارند: method UserModel یک کاربر برمی‌گرداند و method TokenModel token‌ها را برمی‌گرداند.

ایجاد activateUserHandler

حالا که ایده کلی نحوه query کردن رابطه کاربر ↔ token در مدل‌های پایگاه‌داده‌مان را داریم، بیایید کد فعال‌سازی کاربر را شروع کنیم.

برای انجام این کار، باید یک endpoint جدید PUT /v1/users/activated به 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 فعال‌سازی یک کاربر خاص

و فرآیند کار به این شکل خواهد بود:

  1. کاربر token فعال‌سازی متنی (که به تازگی در ایمیل خود دریافت کرده) را به endpoint PUT /v1/users/activated ارسال می‌کند.
  2. ما token متنی را اعتبارسنجی می‌کنیم تا مطمئن شویم با فرمت مورد انتظار مطابقت دارد و در صورت لزوم پیام خطا برای کلاینت ارسال می‌کنیم.
  3. سپس method UserModel.GetForToken() را فراخوانی می‌کنیم تا جزئیات کاربر مرتبط با token ارائه شده را بازیابی کنیم. اگر token مطابقی یافت نشد یا منقضی شده باشد، پیام خطا برای کلاینت ارسال می‌کنیم.
  4. کاربر مرتبط را با تنظیم activated = true روی رکورد کاربر فعال می‌کنیم و آن را در پایگاه‌داده به‌روزرسانی می‌کنیم.
  5. تمام token‌های فعال‌سازی کاربر را از جدول tokens حذف می‌کنیم. می‌توانیم از method TokenModel.DeleteAllForUser() که قبلاً ساختیم استفاده کنیم.
  6. جزئیات به‌روزرسانی شده کاربر را در یک پاسخ JSON ارسال می‌کنیم.

بیایید در فایل cmd/api/users.go شروع کنیم و activateUserHandler جدید را برای اجرای این مراحل ایجاد کنیم:

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

...

func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) {
    // Parse the plaintext activation token from the request body.
    var input struct {
        TokenPlaintext string `json:"token"`
    }

    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    // Validate the plaintext token provided by the client.
    v := validator.New()

    if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    // Retrieve the details of the user associated with the token using the 
    // GetForToken() method (which we will create in a minute). If no matching record
    // is found, then we let the client know that the token they provided is not valid.
    user, err := app.models.Users.GetForToken(data.ScopeActivation, input.TokenPlaintext)
    if err != nil {
        switch {
        case errors.Is(err, data.ErrRecordNotFound):
            v.AddError("token", "invalid or expired activation token")
            app.failedValidationResponse(w, r, v.Errors)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    // Update the user's activation status.
    user.Activated = true

    // Save the updated user record in our database, checking for any edit conflicts in
    // the same way that we did for our movie records.
    err = app.models.Users.Update(user)
    if err != nil {
        switch {
        case errors.Is(err, data.ErrEditConflict):
            app.editConflictResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    // If everything went successfully, then we delete all activation tokens for the
    // user.
    err = app.models.Tokens.DeleteAllForUser(data.ScopeActivation, user.ID)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // Send the updated user details to the client in a JSON response.
    err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

اگر در این مرحله سعی در کامپایل برنامه داشته باشید، خطایی دریافت خواهید کرد زیرا method UserModel.GetForToken() هنوز وجود ندارد. بیایید همین الان آن را ایجاد کنیم.

method UserModel.GetForToken

همانطور که در بالا ذکر کردیم، می‌خواهیم method UserModel.GetForToken() جزئیات کاربر مرتبط با یک token فعال‌سازی خاص را بازیابی کند. اگر token مطابقی یافت نشد یا منقضی شده باشد، می‌خواهیم به جای آن خطای ErrRecordNotFound برگرداند.

برای انجام این کار، باید query SQL زیر را روی پایگاه‌داده خود اجرا کنیم:

SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version
FROM users
INNER JOIN tokens
ON users.id = tokens.user_id
WHERE tokens.hash = $1
AND tokens.scope = $2 
AND tokens.expiry > $3

این query پیچیده‌تر از بیشتر query‌های SQL است که تاکنون استفاده کرده‌ایم، پس بگذارید لحظه‌ای توضیح دهیم چه کاری انجام می‌دهد.

در این query ما از INNER JOIN استفاده می‌کنیم تا اطلاعات از جداول users و tokens را به هم بپیوندیم. به طور خاص، از عبارت ON users.id = tokens.user_id استفاده می‌کنیم تا مشخص کنیم می‌خواهیم رکوردها را جایی که مقدار id کاربر برابر با user_id token است پیوند دهیم.

در پشت صحنه، می‌توانید INNER JOIN را به عنوان ایجاد یک جدول 'موقت' حاوی داده‌های پیوسته از هر دو جدول در نظر بگیرید. سپس، در query SQL خود، از عبارت WHERE استفاده می‌کنیم تا این جدول موقت را فیلتر کنیم و فقط ردیف‌هایی را باقی بگذاریم که hash token و scope token با مقادیر پارامترهای placeholder خاصی مطابقت دارند و انقضای token بعد از یک زمان خاص باشد. از آنجایی که hash token نیز یک کلید اولیه است، همیشه فقط یک رکورد باقی می‌ماند که حاوی جزئیات کاربر مرتبط با hash token است (یا اصلاً هیچ رکوردی اگر token مطابقی وجود نداشته باشد).

اگر دنبال می‌کنید، فایل internal/data/users.go خود را باز کنید و method GetForToken() را اضافه کنید که این query SQL را به این شکل اجرا می‌کند:

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

import (
    "context"
    "crypto/sha256" // New import
    "database/sql"
    "errors"
    "time"

    "greenlight.alexedwards.net/internal/validator"

    "golang.org/x/crypto/bcrypt"
)

...

func (m UserModel) GetForToken(tokenScope, tokenPlaintext string) (*User, error) {
    // Calculate the SHA-256 hash of the plaintext token provided by the client.
    // Remember that this returns a byte *array* with length 32, not a slice.
    tokenHash := sha256.Sum256([]byte(tokenPlaintext))

    // Set up the SQL query.
    query := `
        SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version
        FROM users
        INNER JOIN tokens
        ON users.id = tokens.user_id
        WHERE tokens.hash = $1
        AND tokens.scope = $2 
        AND tokens.expiry > $3`

    // Create a slice containing the query arguments. Notice how we use the [:] operator
    // to get a slice containing the token hash, rather than passing in the array (which
    // is not supported by the pq driver), and that we pass the current time as the
    // value to check against the token expiry.
    args := []any{tokenHash[:], tokenScope, time.Now()}

    var user User

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

    // Execute the query, scanning the return values into a User struct. If no matching
    // record is found we return an ErrRecordNotFound error.
    err := m.DB.QueryRowContext(ctx, query, args...).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 the matching user.
    return &user, nil
}

حالا که این بخش آماده شد، آخرین کاری که باید انجام دهیم اضافه کردن endpoint PUT /v1/users/activated به فایل cmd/api/routes.go است. به این شکل:

فایل: 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)
    // Add the route for the PUT /v1/users/activated endpoint.
    router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)

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

به عنوان توضیح اضافی، باید به طور خلاصه توضیح دهم که دلیل استفاده از PUT به جای POST برای این endpoint این است که idempotent است.

اگر کلاینت درخواست PUT /v1/users/activated یکسانی را چندین بار ارسال کند، اولین درخواست موفق خواهد شد (با فرض معتبر بودن token) و سپس هر درخواست بعدی باعث ارسال خطا به کلاینت می‌شود (زیرا token استفاده شده و از پایگاه‌داده حذف شده است). اما نکته مهم این است که هیچ تغییری در وضعیت برنامه ما (یعنی پایگاه‌داده) پس از آن درخواست اول اتفاق نمی‌افتد.

به طور کلی، هیچ اثر جانبی در وضعیت برنامه از ارسال درخواست یکسان چندین بار توسط کلاینت وجود ندارد، که به این معنی است که endpoint idempotent است و استفاده از PUT مناسب‌تر از POST است.

خب، بیایید API را مجدداً راه‌اندازی کنیم و سپس آن را امتحان کنیم.

اول، سعی کنید چند درخواست به endpoint PUT /v1/users/activated با چند token نامعتبر ارسال کنید. باید پیام‌های خطای مناسب را در پاسخ دریافت کنید، مانند این:

$ curl -X PUT -d '{"token": "invalid"}' localhost:4000/v1/users/activated
{
    "error": {
        "token": "must be 26 bytes long"
    }
}

$ curl -X PUT -d '{"token": "ABCDEFGHIJKLMNOPQRSTUVWXYZ"}' localhost:4000/v1/users/activated
{
    "error": {
        "token": "invalid or expired activation token"
    }
}

سپس سعی کنید درخواستی با یک token فعال‌سازی معتبر از یکی از ایمیل‌های خود (که اگر دنبال می‌کنید در صندوق Mailtrap شما خواهد بود) ارسال کنید. در مورد من، از token P4B3URJZJ2NW5UPZC2OHN4H2NM برای فعال‌سازی کاربر faith@example.com (که در فصل قبل ایجاد کردیم) استفاده می‌کنم.

باید پاسخ JSON با فیلد activated دریافت کنید که تأیید می‌کند کاربر فعال شده است، مشابه این:

$ curl -X PUT -d '{"token": "P4B3URJZJ2NW5UPZC2OHN4H2NM"}' localhost:4000/v1/users/activated
{
    "user": {
        "id": 7,
        "created_at": "2021-04-15T20:25:41+02:00",
        "name": "Faith Smith",
        "email": "faith@example.com",
        "activated": true
    }
}

و اگر سعی کنید درخواست را دوباره با همان token تکرار کنید، حالا باید خطای "invalid or expired activation token" دریافت کنید به دلیل اینکه تمام token‌های فعال‌سازی faith@example.com را حذف کرده‌ایم.

$ curl -X PUT -d '{"token": "P4B3URJZJ2NW5UPZC2OHN4H2NM"}' localhost:4000/v1/users/activated
{
    "error": {
        "token": "invalid or expired activation token"
    }
}

در نهایت، بیایید نگاهی سریع به پایگاه‌داده خود بیندازیم تا وضعیت جدول users خود را ببینیم.

$ 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 email, activated, version FROM users;
       email       | activated | version 
-------------------+-----------+---------
 alice@example.com | f         |       1
 bob@example.com   | f         |       1
 carol@example.com | f         |       1
 dave@example.com  | f         |       1
 edith@example.com | f         |       1
 faith@example.com | t         |       2

در مقایسه با تمام کاربران دیگر، می‌بینیم که faith@example.com حالا مقدار activated = true دارد و شماره نسخه رکورد کاربر آنها به 2 افزایش یافته است.


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

فرآیند برنامه وب

اگر API شما backend یک وب‌سایت است، نه یک سرویس کاملاً مستقل، می‌توانید فرآیند فعال‌سازی را طوری تنظیم کنید که برای کاربران ساده‌تر و بصری‌تر باشد و در عین حال امن باقی بماند.

دو گزینه اصلی اینجا وجود دارد. اولین و قوی‌ترین گزینه این است که از کاربر بخواهید token را در فرمی در وب‌سایت شما کپی و پیست کند که سپس درخواست PUT /v1/users/activate را برای آنها با استفاده از JavaScript انجام می‌دهد. ایمیل خوش‌آمدگویی برای پشتیبانی از این فرآیند می‌تواند چیزی شبیه این باشد:

سلام,

ممنون از ثبت‌نام برای حساب Greenlight. از اینکه به جمع ما پیوستید خوشحالیم!

برای مراجعه آینده، شماره شناسه کاربری شما 123 است.

برای فعال‌سازی حساب Greenlight خود لطفاً به https://example.com/users/activate مراجعه کنید و
کد زیر را وارد کنید:

--------------------------
RMMCV3MZCEBYQADXBODCLTAF6L
--------------------------

لطفاً توجه داشته باشید که این کد در 3 روز منقضی می‌شود و فقط یک بار قابل استفاده است.

ممنون،

تیم Greenlight

این رویکرد اساساً ساده و امن است — در واقع وب‌سایت شما فقط فرمی فراهم می‌کند که درخواست PUT را برای کاربر انجام می‌دهد، به جای اینکه نیاز باشد آنها به صورت دستی با curl یا ابزار دیگری این کار را انجام دهند.

به طور جایگزین، اگر نمی‌خواهید کاربر token را کپی و پیست کند، می‌توانید از آنها بخواهید روی لینکی حاوی token کلیک کنند که آنها را به صفحه‌ای در وب‌سایت شما می‌برد. مشابه این:

سلام,

ممنون از ثبت‌نام برای حساب Greenlight. از اینکه به جمع ما پیوستید خوشحالیم!

برای مراجعه آینده، شماره شناسه کاربری شما 123 است.

برای فعال‌سازی حساب Greenlight خود لطفاً روی لینک زیر کلیک کنید:

https://example.com/users/activate?token=RMMCV3MZCEBYQADXBODCLTAF6L

لطفاً توجه داشته باشید که این لینک در 3 روز منقضی می‌شود و فقط یک بار قابل استفاده است.

ممنون،

تیم Greenlight

این صفحه سپس باید دکمه‌ای نمایش دهد که چیزی مانند 'تأیید فعال‌سازی حساب شما' می‌گوید، و JavaScript روی صفحه وب می‌تواند token را از URL استخراج کند و آن را هنگام کلیک کاربر روی دکمه به endpoint API PUT /v1/users/activate شما ارسال کند.

اگر این گزینه دوم را انتخاب کنید، همچنین باید اقداماتی برای جلوگیری از نشت token در هدر referrer انجام دهید اگر کاربر به سایت دیگری ناوبری کند. می‌توانید از هدر Referrer-Policy: Origin یا تگ HTML <meta name="referrer" content="origin"> برای کاهش این مشکل استفاده کنید، اگرچه باید بدانید که توسط تمام مرورگرهای وب پشتیبانی نمی‌شود (پشتیبانی در حال حاضر 97% است).

اما در تمام موارد، هرچند ایمیل و فرآیند از نظر رابط کاربری و تجربه کاربری به نظر برسد، endpoint API backend که پیاده‌سازی کرده‌ایم یکسان است و نیازی به تغییر ندارد.

حمله زمان‌بندی query SQL

ارزش دارد اشاره کنیم که query SQL که در UserModel.GetForToken() استفاده می‌کنیم از نظر تئوری در برابر حمله زمان‌بندی آسیب‌پذیر است، زیرا ارزیابی PostgreSQL از شرط tokens.hash = $1 در زمان ثابت انجام نمی‌شود.

SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version
FROM users
INNER JOIN tokens
ON users.id = tokens.user_id
WHERE tokens.hash = $1    --<-- This is vulnerable to a timing attack
AND tokens.scope = $2 
AND tokens.expiry > $3

اگرچه انجام آن تا حدی دشوار است، از نظر تئوری یک مهاجم می‌تواند هزاران درخواست به endpoint PUT /v1/users/activated ما ارسال کند و تفاوت‌های کوچک در زمان پاسخ متوسط را تحلیل کند تا تصویری از مقدار hash token فعال‌سازی در پایگاه‌داده بسازد.

اما، در مورد ما، حتی اگر حمله زمان‌بندی موفق باشد، فقط مقدار token hash شده را از پایگاه‌داده فاش می‌کند — نه مقدار token متنی که کاربر واقعاً برای فعال‌سازی حساب خود نیاز دارد ارسال کند.

پس مهاجم همچنان نیاز دارد از brute-force برای پیدا کردن رشته 26 کاراکتری که دقیقاً همان hash SHA-256 کشف شده از حمله زمان‌بندی را دارد استفاده کند. این کار بسیار دشوار است و با فناوری فعلی عملی نیست.