بریم احراز هویت کاربر › ثبت‌نام کاربر و رمزنگاری رمز عبور
قبلی · فهرست · بعدی
فصل 10.3.

ثبت‌نام کاربر و رمزنگاری رمز عبور (User Signup and Password Encryption)

در این بخش، نحوه پیاده‌سازی ثبت‌نام کاربر (User Signup) و رمزنگاری رمز عبور (Password Encryption) را بررسی می‌کنیم. این شامل فرم ثبت‌نام (Signup Form)، اعتبارسنجی داده‌ها (Data Validation) و ذخیره‌سازی امن (Secure Storage) می‌شود.

برای شروع، بیایید یک فرم HTML (HTML Form) برای ثبت‌نام کاربر (User Registration) ایجاد کنیم:

$ touch ui/html/pages/signup.tmpl
File: ui/html/pages/signup.tmpl
{{define "title"}}Signup{{end}}

{{define "main"}}
<form action='/user/signup' method='POST' novalidate>
    <div>
        <label>Name:</label>
        {{with .Form.FieldErrors.name}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='text' name='name' value='{{.Form.Name}}'>
    </div>
    <div>
        <label>Email:</label>
        {{with .Form.FieldErrors.email}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='email' name='email' value='{{.Form.Email}}'>
    </div>
    <div>
        <label>Password:</label>
        {{with .Form.FieldErrors.password}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='password' name='password'>
    </div>
    <div>
        <input type='submit' value='Signup'>
    </div>
</form>
{{end}}

امیدوارم تا اینجا برایتان آشنا باشد. برای فرم ثبت‌نام، ما از همان ساختار فرم که قبلاً در کتاب استفاده کردیم، استفاده می‌کنیم، با سه فیلد: name، email و password (که از نوع‌های ورودی HTML5 مربوطه استفاده می‌کنند).

سپس فایل cmd/web/handlers.go خود را به‌روزرسانی کنید تا یک ساختار جدید userSignupForm (که داده‌های فرم را نمایندگی و نگهداری می‌کند) ایجاد کنید و آن را به هندلر userSignup متصل کنید.

به این صورت:

File: cmd/web/handlers.go
package main

...

// Create a new userSignupForm struct.
type userSignupForm struct {
    Name                string `form:"name"`
    Email               string `form:"email"`
    Password            string `form:"password"`
    validator.Validator `form:"-"`
}

// Update the handler so it displays the signup page.
func (app *application) userSignup(w http.ResponseWriter, r *http.Request) {
    data := app.newTemplateData(r)
    data.Form = userSignupForm{}
    app.render(w, r, http.StatusOK, "signup.tmpl", data)
}

...

اگر برنامه را اجرا کنید و به https://localhost:4000/user/signup بروید، باید اکنون صفحه‌ای شبیه به این را ببینید:

10.03-01.png

اعتبارسنجی ورودی کاربر

وقتی این فرم ارسال می‌شود، داده‌ها به هندلر userSignupPost که قبلاً ساخته‌ایم ارسال می‌شود.

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

  1. بررسی کنیم که نام، آدرس ایمیل و رمز عبور ارائه شده خالی نباشند.
  2. فرمت آدرس ایمیل را بررسی کنیم.
  3. اطمینان حاصل کنیم که رمز عبور حداقل 8 کاراکتر طول دارد.
  4. مطمئن شویم که آدرس ایمیل قبلاً استفاده نشده است.

می‌توانیم سه بررسی اول را با بازگشت به فایل internal/validator/validator.go و ایجاد دو متد کمکی جدید — MinChars() و Matches() — به همراه یک عبارت منظم برای بررسی فرمت ایمیل پوشش دهیم.

به این صورت:

File: internal/validator/validator.go
package validator

import (
    "regexp" // New import
    "slices"
    "strings"
    "unicode/utf8"
)

// Use the regexp.MustCompile() function to parse a regular expression pattern
// for sanity checking the format of an email address. This returns a pointer to 
// a 'compiled' regexp.Regexp type, or panics in the event of an error. Parsing 
// this pattern once at startup and storing the compiled *regexp.Regexp in a 
// variable is more performant than re-parsing the pattern each time we need it.
var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")

...

// MinChars() returns true if a value contains at least n characters.
func MinChars(value string, n int) bool {
    return utf8.RuneCountInString(value) >= n
}

// Matches() returns true if a value matches a provided compiled regular 
// expression pattern.
func Matches(value string, rx *regexp.Regexp) bool {
    return rx.MatchString(value)
}

چند نکته درباره الگوی عبارت منظم EmailRX وجود دارد که می‌خواهم به سرعت به آن‌ها اشاره کنم:

اما به هر حال، من در حال انحراف هستم. بیایید به کار اصلی برگردیم.

به فایل handlers.go خود بروید و کدی برای پردازش فرم و اجرای بررسی‌های اعتبارسنجی اضافه کنید به این صورت:

File: cmd/web/handlers.go
package main

...

func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
    // Declare an zero-valued instance of our userSignupForm struct.
    var form userSignupForm

    // Parse the form data into the userSignupForm struct.
    err := app.decodePostForm(r, &form)
    if err != nil {
        app.clientError(w, http.StatusBadRequest)
        return
    }

    // Validate the form contents using our helper functions.
    form.CheckField(validator.NotBlank(form.Name), "name", "This field cannot be blank")
    form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
    form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
    form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
    form.CheckField(validator.MinChars(form.Password, 8), "password", "This field must be at least 8 characters long")

    // If there are any errors, redisplay the signup form along with a 422
    // status code.
    if !form.Valid() {
        data := app.newTemplateData(r)
        data.Form = form
        app.render(w, r, http.StatusUnprocessableEntity, "signup.tmpl", data)
        return
    }

    // Otherwise send the placeholder response (for now!).
    fmt.Fprintln(w, "Create a new user...")
}

...

برنامه را اجرا کنید و داده‌های نامعتبر را در فرم ثبت‌نام وارد کنید، مانند این:

10.03-02.png

و اگر سعی کنید آن را ارسال کنید، باید خطاهای اعتبارسنجی مناسب را به این صورت ببینید:

10.03-03.png

تنها چیزی که باقی مانده است، بررسی چهارم اعتبارسنجی است: مطمئن شوید که آدرس ایمیل قبلاً استفاده نشده است. این کمی پیچیده‌تر است.

از آنجا که ما یک محدودیت UNIQUE بر روی فیلد email جدول users داریم، تضمین شده است که دو کاربر با همان آدرس ایمیل در پایگاه داده ما وجود نخواهند داشت. بنابراین از نظر منطق کسب و کار و یکپارچگی داده‌ها، ما در حال حاضر خوب هستیم. اما سوال باقی می‌ماند که چگونه هرگونه مشکل ایمیل قبلاً استفاده شده را به کاربر اطلاع دهیم. ما این را در پایان فصل بررسی خواهیم کرد.

مقدمه‌ای کوتاه بر bcrypt

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

بهتر است — در واقع، ضروری است — که یک هش یکطرفه از رمز عبور ذخیره کنید، که با یک تابع مشتق کلید محاسباتی گران مانند Argon2، scrypt یا bcrypt به دست آمده است. Go پیاده‌سازی‌هایی از هر 3 الگوریتم در بسته golang.org/x/crypto دارد.

اما یک نکته مثبت از پیاده‌سازی bcrypt این است که شامل توابع کمکی خاصی برای هش کردن و بررسی رمزهای عبور است، و این همان چیزی است که ما در اینجا استفاده خواهیم کرد.

اگر در حال دنبال کردن هستید، لطفاً آخرین نسخه از بسته golang.org/x/crypto/bcrypt را دانلود کنید:

$ go get golang.org/x/crypto/bcrypt@latest
go: downloading golang.org/x/crypto v0.26.0
go get: added golang.org/x/crypto v0.26.0

دو تابع وجود دارد که ما در این کتاب استفاده خواهیم کرد. اولین تابع bcrypt.GenerateFromPassword() است که به ما اجازه می‌دهد یک هش از یک رمز عبور متن ساده ایجاد کنیم به این صورت:

hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)

این تابع یک هش 60 کاراکتری برمی‌گرداند که شبیه به این است:

$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG

پارامتر دوم که به bcrypt.GenerateFromPassword() می‌دهیم نشان‌دهنده هزینه است، که با یک عدد صحیح بین 4 و 31 نمایش داده می‌شود. مثال بالا از هزینه 12 استفاده می‌کند، که به این معنی است که 4096 (2^12) تکرار bcrypt برای تولید هش رمز عبور استفاده خواهد شد.

هرچه هزینه بالاتر باشد، هش برای یک مهاجم برای شکستن آن گران‌تر خواهد بود (که چیز خوبی است). اما هزینه بالاتر همچنین به این معنی است که برنامه ما باید کار بیشتری برای ایجاد هش رمز عبور انجام دهد وقتی که یک کاربر ثبت‌نام می‌کند — و این به معنای استفاده بیشتر از منابع توسط برنامه شما و تأخیر اضافی برای کاربر نهایی است. بنابراین انتخاب یک مقدار هزینه مناسب یک عمل تعادل است. هزینه 12 یک حداقل معقول است، اما اگر ممکن است باید تست بار انجام دهید، و اگر می‌توانید هزینه را بدون تأثیر منفی بر تجربه کاربر بالاتر ببرید، باید این کار را انجام دهید.

از طرف دیگر، می‌توانیم بررسی کنیم که یک رمز عبور متن ساده با یک هش خاص مطابقت دارد یا خیر با استفاده از تابع bcrypt.CompareHashAndPassword() به این صورت:

hash := []byte("$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6GzGJSWG")
err := bcrypt.CompareHashAndPassword(hash, []byte(password))

تابع bcrypt.CompareHashAndPassword() nil را برمی‌گرداند اگر رمز عبور متن ساده با یک هش خاص مطابقت داشته باشد، یا یک خطا اگر مطابقت نداشته باشند.

ذخیره جزئیات کاربر

مرحله بعدی ساخت ما این است که متد UserModel.Insert() را به‌روزرسانی کنیم تا یک رکورد جدید در جدول users ما ایجاد کند که شامل نام، ایمیل و رمز عبور هش شده معتبر باشد.

این به دو دلیل جالب خواهد بود: اول اینکه می‌خواهیم هش bcrypt رمز عبور را ذخیره کنیم (نه خود رمز عبور) و دوم، ما همچنین باید خطای احتمالی ناشی از تکرار ایمیل که محدودیت UNIQUE را نقض می‌کند، مدیریت کنیم.

تمام خطاهایی که توسط MySQL برگردانده می‌شوند یک کد خاص دارد، که می‌توانیم از آن برای تشخیص علت خطا استفاده کنیم (لیست کامل کدهای خطا و توضیحات MySQL را می‌توانید اینجا پیدا کنید). در مورد تکرار ایمیل، کد خطا 1062 (ER_DUP_ENTRY) خواهد بود.

فایل internal/models/users.go را باز کنید و آن را به‌روزرسانی کنید تا شامل کد زیر باشد:

File: internal/models/users.go
package models

import (
    "database/sql"
    "errors"  // New import
    "strings" // New import
    "time"

    "github.com/go-sql-driver/mysql" // New import
    "golang.org/x/crypto/bcrypt"     // New import
)

...

type UserModel struct {
    DB *sql.DB
}

func (m *UserModel) Insert(name, email, password string) error {
    // Create a bcrypt hash of the plain-text password.
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
    if err != nil {
        return err
    }

    stmt := `INSERT INTO users (name, email, hashed_password, created)
    VALUES(?, ?, ?, UTC_TIMESTAMP())`

    // Use the Exec() method to insert the user details and hashed password
    // into the users table.
    _, err = m.DB.Exec(stmt, name, email, string(hashedPassword))
    if err != nil {
        // If this returns an error, we use the errors.As() function to check
        // whether the error has the type *mysql.MySQLError. If it does, the
        // error will be assigned to the mySQLError variable. We can then check
        // whether or not the error relates to our users_uc_email key by
        // checking if the error code equals 1062 and the contents of the error 
        // message string. If it does, we return an ErrDuplicateEmail error.
        var mySQLError *mysql.MySQLError
        if errors.As(err, &mySQLError) {
            if mySQLError.Number == 1062 && strings.Contains(mySQLError.Message, "users_uc_email") {
                return ErrDuplicateEmail
            }
        }
        return err
    }

    return nil
}

...

سپس می‌توانیم این همه را با به‌روزرسانی هندلر userSignup به این صورت به پایان برسانیم:

File: cmd/web/handlers.go
package main

...

func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
    var form userSignupForm

    err := app.decodePostForm(r, &form)
    if err != nil {
        app.clientError(w, http.StatusBadRequest)
        return
    }

    form.CheckField(validator.NotBlank(form.Name), "name", "This field cannot be blank")
    form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
    form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
    form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
    form.CheckField(validator.MinChars(form.Password, 8), "password", "This field must be at least 8 characters long")

    if !form.Valid() {
        data := app.newTemplateData(r)
        data.Form = form
        app.render(w, r, http.StatusUnprocessableEntity, "signup.tmpl", data)
        return
    }

    // Otherwise send the placeholder response (for now!).
    fmt.Fprintln(w, "Create a new user...")
}

...

فایل‌ها را ذخیره کنید، برنامه را مجدداً راه‌اندازی کنید و سعی کنید برای یک حساب کاربری ثبت‌نام کنید. حتماً آدرس ایمیل و رمز عبوری که استفاده می‌کنید را به خاطر بسپارید… در فصل بعدی به آن‌ها نیاز خواهید داشت!

10.03-04.png

اگر همه چیز به درستی کار کند، باید ببینید که مرورگر شما پس از ارسال فرم به https://localhost:4000/user/login هدایت می‌شود.

10.03-05.png

در این مرحله ارزش دارد که پایگاه داده MySQL خود را باز کنید و به محتوای جدول users نگاه کنید. باید یک رکورد جدید با جزئیاتی که برای ثبت‌نام استفاده کرده‌اید و یک هش bcrypt از رمز عبور ببینید.

mysql> SELECT * FROM users;
+----+-----------+-----------------+--------------------------------------------------------------+---------------------+
| id | name      | email           | hashed_password                                              | created             |
+----+-----------+-----------------+--------------------------------------------------------------+---------------------+
|  1 | Bob Jones | bob@example.com | $2a$12$mNXQrOwVWp/TqAzCCyDoyegtpV40EXwrzVLnbFpHPpWdvnmIoZ.Q. | 2024-03-18 11:29:23 |
+----+-----------+-----------------+--------------------------------------------------------------+---------------------+
1 row in set (0.01 sec)

اگر دوست دارید، به فرم ثبت‌نام برگردید و یک حساب کاربری دیگر با همان آدرس ایمیل اضافه کنید. باید یک خطای اعتبارسنجی به این صورت دریافت کنید:

10.03-06.png

اطلاعات اضافی

استفاده از پیاده‌سازی‌های bcrypt پایگاه داده

برخی از پایگاه‌های داده توابع داخلی ارائه می‌دهند که می‌توانید برای هش کردن و تأیید رمز عبور به جای پیاده‌سازی خودتان در Go، مانند آنچه در کد بالا انجام داده‌ایم، استفاده کنید.

اما احتمالاً بهتر است از استفاده از این‌ها به دلیل دو دلیل اجتناب کنید:

جایگزین‌هایی برای بررسی تکرار ایمیل

می‌دانم که کد در متد UserModel.Insert() ما خیلی زیبا نیست و بررسی خطای برگردانده شده توسط MySQL کمی ناپایدار به نظر می‌رسد. اگر نسخه‌های آینده MySQL شماره‌های خطای خود را تغییر دهند چه؟ یا فرمت پیام‌های خطای خود را تغییر دهند؟

یک گزینه جایگزین (اما همچنین ناقص) این است که یک متد UserModel.EmailTaken() به مدل خود اضافه کنیم که بررسی کند آیا کاربری با یک ایمیل خاص قبلاً وجود دارد یا خیر. می‌توانیم این را قبل از اینکه سعی کنیم یک رکورد جدید وارد کنیم، فراخوانی کنیم و یک پیام خطای اعتبارسنجی به فرم اضافه کنیم.

با این حال، این یک شرایط مسابقه به برنامه ما معرفی می‌کند. اگر دو کاربر سعی کنند با همان آدرس ایمیل در دقیقاً همان زمان ثبت‌نام کنند، هر دو ارسال اعتبارسنجی را می‌گذرانند اما در نهایت تنها یک INSERT به پایگاه داده MySQL موفق خواهد بود. دیگری محدودیت UNIQUE ما را نقض می‌کند و کاربر در نهایت یک پاسخ 500 Internal Server Error دریافت می‌کند.

واژه‌نامه اصطلاحات فنی

اصطلاح فارسی معادل انگلیسی توضیح
ثبت‌نام کاربر User Signup فرآیند ایجاد حساب کاربری
رمزنگاری رمز عبور Password Encryption کدگذاری رمز عبور
فرم ثبت‌نام Signup Form فرم ورود اطلاعات کاربر
اعتبارسنجی داده‌ها Data Validation بررسی صحت داده‌ها
ذخیره‌سازی امن Secure Storage نگهداری ایمن اطلاعات
فرم HTML HTML Form فرم ورود اطلاعات
ثبت‌نام کاربر User Registration ایجاد حساب کاربری جدید
هش کردن Hashing تبدیل رمز به کد امن
نمک رمزنگاری Encryption Salt داده تصادفی برای امنیت بیشتر
خطای اعتبارسنجی Validation Error پیام خطای ورودی نامعتبر