Let's Go احراز هویت کاربر › ثبت‌نام کاربر و رمزنگاری رمز عبور
قبلی · فهرست · بعدی
فصل ۱۰.۳.

ثبت‌نام کاربر و رمزگذاری رمز عبور

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

بروید و یک فایل جدید ui/html/pages/signup.tmpl حاوی نشانه‌گذاری زیر برای فرم ثبت‌نام ایجاد کنید.

$ 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 خود را به‌روزرسانی کنیم تا یک struct جدید userSignupForm را شامل شود (که داده‌های فرم را نمایش می‌دهد و نگه می‌دارد)، و آن را به handler 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

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

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

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

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

می‌توانیم سه بررسی اول را با بازگشت به فایل internal/validator/validator.go خود و ایجاد دو متد helper جدید — 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 خود داریم، از قبل تضمین شده است که دو کاربر با آدرس ایمیل یکسان در پایگاه داده نخواهیم داشت. بنابراین از نظر منطق کسب‌وکار (business logic) و یکپارچگی داده (data integrity) ما از قبل خوب هستیم. اما سؤال در مورد نحوه ارتباط مشکل ایمیل قبلاً استفاده شده به کاربر باقی می‌ماند. این را در پایان فصل حل خواهیم کرد.

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

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

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

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

اگر در حال دنبال کردن هستید، لطفاً بروید و آخرین نسخه بسته 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 از یک رمز عبور متنی ساده داده شده را می‌دهد، به این صورت:

hash, err := bcrypt.GenerateFromPassword([]byte("my plain text password"), 12)

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

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

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

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

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

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

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

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

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

این از دو نظر جالب خواهد بود: اول می‌خواهیم hash 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
}

...

سپس می‌توانیم همه این کارها را با به‌روزرسانی handler 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
    }

    // Try to create a new user record in the database. If the email already
    // exists then add an error message to the form and re-display it.
    err = app.users.Insert(form.Name, form.Email, form.Password)
    if err != nil {
        if errors.Is(err, models.ErrDuplicateEmail) {
            form.AddFieldError("email", "Email address is already in use")

            data := app.newTemplateData(r)
            data.Form = form
            app.render(w, r, http.StatusUnprocessableEntity, "signup.tmpl", data)
        } else {
            app.serverError(w, r, err)
        }

        return
    }

    // Otherwise add a confirmation flash message to the session confirming that
    // their signup worked.
    app.sessionManager.Put(r.Context(), "flash", "Your signup was successful. Please log in.")

    // And redirect the user to the login page.
    http.Redirect(w, r, "/user/login", http.StatusSeeOther)
}

...

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

10.03-04.png

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

10.03-05.png

در این مرحله ارزش دارد که پایگاه داده MySQL خود را باز کنید و محتوای جدول users را بررسی کنید. باید یک رکورد جدید با جزئیاتی که برای ثبت‌نام استفاده کردید و یک hash 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 پایگاه داده

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

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

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

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

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

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

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