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

ایجاد توکن‌های فعال‌سازی امن

یکپارچگی فرآیند فعال‌سازی ما به یک نکته کلیدی بستگی دارد: ‘حدس‌ناپذیری’ توکنی که به آدرس ایمیل کاربر ارسال می‌کنیم. اگر توکن به راحتی قابل حدس زدن باشد یا بتوان آن را به صورت brute-force کرد، در آن صورت مهاجم می‌تواند حساب کاربر را حتی بدون دسترسی به صندوق ورودی ایمیل کاربر فعال کند.

به همین دلیل، می‌خواهیم توکن توسط یک مولد اعداد تصادفی امن رمزنگاری‌شده (CSPRNG) تولید شود و دارای انتروپی (یا تصادفیت) کافی باشد به طوری که حدس زدن آن غیرممکن باشد. از Go 1.24 به بعد، یک روش ساده برای ایجاد توکنی که این معیارها را دارد، استفاده از تابع rand.Text() از پکیج crypto/rand است.

این توکن‌هایی با ۱۲۸ بیت (۱۶ بایت) انتروپی تولید می‌کند که با الفبای استاندارد base32 رمزگذاری شده‌اند. در عمل، این به این معنی است که rand.Text() رشته‌هایی به طول ۲۶ کاراکتر برمی‌گرداند که به این شکل هستند:

CN5MWVETIILGP32FBV3EOGBNRV
LYDISI72PTLGTIVEDSV5IATEAR
EADOYJU5WJC3CCR3KZPSJW5BJA

اگر می‌خواهید همراه ما پیش بروید، یک فایل جدید internal/data/tokens.go ایجاد کنید. این فایل به عنوان خانه تمام منطق مربوط به ایجاد و مدیریت توکن‌ها در چند فصل آینده عمل خواهد کرد.

$ touch internal/data/tokens.go

سپس در این فایل یک struct به نام Token (برای نمایش داده‌های یک توکن خاص) و یک تابع generateToken() که می‌توانیم برای ایجاد توکن جدید استفاده کنیم، تعریف می‌کنیم.

این یکی دیگر از مواقعی است که احتمالاً بهتر است مستقیماً وارد کد شویم و در حین کار توضیح دهیم چه اتفاقی می‌افتد.

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

import (
    "crypto/rand"
    "crypto/sha256"
    "encoding/base32"
    "time"
)

// Define constants for the token scope. For now we just define the scope "activation"
// but we'll add additional scopes later in the book.
const (
    ScopeActivation = "activation"
)

// Define a Token struct to hold the data for an individual token. This includes the 
// plaintext and hashed versions of the token, associated user ID, expiry time and 
// scope.
type Token struct {
    Plaintext string
    Hash      []byte
    UserID    int64
    Expiry    time.Time
    Scope     string
}

func generateToken(userID int64, ttl time.Duration, scope string) *Token {
    // Create a Token instance. In this, we set the Plaintext field to be a random 
    // token generated by rand.Text(), and also set values for the user ID, expiry, and 
    // scope of the token. Notice that we add the provided ttl (time-to-live) duration 
    // argument to the current time to get the expiry time.
    token := &Token{
        Plaintext: rand.Text(),
        UserID:    userID,
        Expiry:    time.Now().Add(ttl),
        Scope:     scope,
    }

    // Generate a SHA-256 hash of the plaintext token string. This will be the value 
    // that we store in the `hash` column of our database table. Note that the 
    // sha256.Sum256() function returns an *array* of length 32, so to make it easier to  
    // work with we convert it to a slice using the [:] operator before storing it.
    hash := sha256.Sum256([]byte(token.Plaintext))
    token.Hash = hash[:]

    return token
}

ایجاد TokenModel و بررسی‌های اعتبارسنجی

بیایید به مسیر خود ادامه دهیم و یک نوع TokenModel راه‌اندازی کنیم که تعاملات پایگاه داده با جدول PostgreSQL tokens ما را در بر می‌گیرد. ما الگوی بسیار مشابهی با MovieModel و UsersModel دنبال خواهیم کرد و سه متد زیر را روی آن پیاده‌سازی می‌کنیم:

همچنین یک تابع جدید ValidateTokenPlaintext() ایجاد خواهیم کرد که بررسی می‌کند آیا یک توکن متن ساده که در آینده توسط کلاینت ارائه می‌شود دقیقاً ۲۶ بایت طول دارد یا خیر.

دوباره فایل internal/data/tokens.go را باز کنید و کد زیر را اضافه کنید:

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

import (
    "context" // New import
    "crypto/rand"
    "crypto/sha256"
    "database/sql" // New import
    "encoding/base32"
    "time"

    "greenlight.alexedwards.net/internal/validator" // New import
)

...

// Check that the plaintext token has been provided and is exactly 26 bytes long.
func ValidateTokenPlaintext(v *validator.Validator, tokenPlaintext string) {
    v.Check(tokenPlaintext != "", "token", "must be provided")
    v.Check(len(tokenPlaintext) == 26, "token", "must be 26 bytes long")
}

// Define the TokenModel type.
type TokenModel struct {
    DB *sql.DB
}

// The New() method is a shortcut which creates a new Token struct and then inserts the
// data in the tokens table.
func (m TokenModel) New(userID int64, ttl time.Duration, scope string) (*Token, error) {
    token := generateToken(userID, ttl, scope)

    err := m.Insert(token)
    return token, err
}

// Insert() adds the data for a specific token to the tokens table.
func (m TokenModel) Insert(token *Token) error {
    query := `
        INSERT INTO tokens (hash, user_id, expiry, scope) 
        VALUES ($1, $2, $3, $4)`

    args := []any{token.Hash, token.UserID, token.Expiry, token.Scope}

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

    _, err := m.DB.ExecContext(ctx, query, args...)
    return err
}

// DeleteAllForUser() deletes all tokens for a specific user and scope.
func (m TokenModel) DeleteAllForUser(scope string, userID int64) error {
    query := `
        DELETE FROM tokens 
        WHERE scope = $1 AND user_id = $2`

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

    _, err := m.DB.ExecContext(ctx, query, scope, userID)
    return err
}

و در نهایت، باید فایل internal/data/models.go را به‌روزرسانی کنیم تا TokenModel جدید در struct والد Models ما گنجانده شود. به این صورت:

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

...

type Models struct {
    Movies MovieModel
    Tokens TokenModel // Add a new Tokens field.
    Users  UserModel
}

func NewModels(db *sql.DB) Models {
    return Models{
        Movies: MovieModel{DB: db},
        Tokens: TokenModel{DB: db}, // Initialize a new TokenModel instance.
        Users:  UserModel{DB: db},
    }
}

در این مرحله باید بتوانید برنامه را مجدداً راه‌اندازی کنید و همه چیز بدون مشکل کار کند.

$ go run ./cmd/api/
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development