راهاندازی مدل کاربران
اکنون که جدول دیتابیس ما راهاندازی شده است، قصد داریم پکیج internal/data خود را بهروزرسانی کنیم تا شامل یک struct جدید به نام User (برای نمایش دادههای یک کاربر خاص) باشد، و یک نوع UserModel ایجاد کنیم (که برای اجرای queryهای مختلف SQL روی جدول users استفاده خواهیم کرد).
اگر قصد دارید همراه ما پیش بروید، یک فایل internal/data/users.go ایجاد کنید تا این کد جدید را در خود جای دهد:
$ touch internal/data/users.go
بیایید با تعریف struct User شروع کنیم، همراه با چند متد کمکی برای تنظیم و تأیید رمز عبور کاربر.
همانطور که قبلاً ذکر کردیم، در این پروژه از bcrypt برای هش کردن رمز عبور کاربر قبل از ذخیرهسازی در دیتابیس استفاده خواهیم کرد. بنابراین اولین کاری که باید انجام دهیم نصب پکیج golang.org/x/crypto/bcrypt است که یک پیادهسازی ساده و قابل استفاده از الگوریتم bcrypt در Go ارائه میدهد.
$ go get golang.org/x/crypto/bcrypt@latest go: downloading golang.org/x/crypto v0.41.0 go get: added golang.org/x/crypto v0.41.0
سپس در فایل internal/data/users.go، struct User و متدهای کمکی را به صورت زیر ایجاد کنید:
package data import ( "errors" "time" "golang.org/x/crypto/bcrypt" ) // Define a User struct to represent an individual user. Importantly, notice how we are // using the json:"-" struct tag to prevent the Password and Version fields from appearing // in any output when we encode it to JSON. Also notice that the Password field uses the // custom password type defined below. type User struct { ID int64 `json:"id"` CreatedAt time.Time `json:"created_at"` Name string `json:"name"` Email string `json:"email"` Password password `json:"-"` Activated bool `json:"activated"` Version int `json:"-"` } // Create a custom password type which is a struct containing the plaintext and hashed // versions of the password for a user. The plaintext field is a *pointer* to a string, // so that we're able to distinguish between a plaintext password not being present in // the struct at all, versus a plaintext password which is the empty string "". type password struct { plaintext *string hash []byte } // The Set() method calculates the bcrypt hash of a plaintext password, and stores both // the hash and the plaintext versions in the struct. func (p *password) Set(plaintextPassword string) error { hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) if err != nil { return err } p.plaintext = &plaintextPassword p.hash = hash return nil } // The Matches() method checks whether the provided plaintext password matches the // hashed password stored in the struct, returning true if it matches and false // otherwise. func (p *password) Matches(plaintextPassword string) (bool, error) { err := bcrypt.CompareHashAndPassword(p.hash, []byte(plaintextPassword)) if err != nil { switch { case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): return false, nil default: return false, err } } return true, nil }
ما نحوه کار پکیج golang.org/x/crypto/bcrypt را قبلاً در Let's Go توضیح دادهایم، اما بیایید بهسرعت نکات کلیدی را مرور کنیم:
تابع
bcrypt.GenerateFromPassword()یک هش bcrypt از رمز عبور با استفاده از یک پارامتر cost مشخص تولید میکند (در کد بالا، از cost برابر با12استفاده کردهایم). هرچه cost بالاتر باشد، تولید هش کندتر و از نظر محاسباتی گرانتر خواهد بود. در اینجا باید تعادلی برقرار شود — ما میخواهیم cost برای مهاجمان به قدری بالا باشد که غیرعملی باشد، اما نه آنقدر کند که به تجربه کاربری API ما آسیب برساند. این تابع یک رشته هش با فرمت زیر برمیگرداند:$2a$[cost]$[22-character salt][31-character hash]
تابع
bcrypt.CompareHashAndPassword()با هش مجدد رمز عبور ارائه شده با استفاده از همان salt و پارامتر cost موجود در رشته هشی که با آن مقایسه میکنیم، کار میکند. سپس مقدار هش شده مجدداً با رشته هش اصلی با استفاده از تابعsubtle.ConstantTimeCompare()مقایسه میشود، که مقایسه را در زمان ثابت انجام میدهد (برای کاهش خطر حمله زمانی). اگر مطابقت نداشته باشند، خطایbcrypt.ErrMismatchedHashAndPasswordبرگردانده میشود.
افزودن بررسیهای اعتبارسنجی
بیایید ادامه دهیم و چند بررسی اعتبارسنجی برای struct User خود ایجاد کنیم. بهطور خاص، میخواهیم:
- بررسی کنیم که فیلد
Nameرشته خالی نباشد و مقدار آن کمتر از ۵۰۰ بایت باشد. - بررسی کنیم که فیلد
Emailرشته خالی نباشد و با عبارت با قاعده (regular expression) برای آدرسهای ایمیل که قبلاً در پکیجvalidatorخود در ابتدای کتاب اضافه کردهایم، مطابقت داشته باشد. - اگر فیلد
Password.plaintextبرابر باnilنباشد، بررسی کنیم که مقدار آن رشته خالی نباشد و بین ۸ تا ۷۲ بایت باشد. - بررسی کنیم که فیلد
Password.hashهرگزnilنباشد.
علاوه بر این، ما میخواهیم بررسیهای اعتبارسنجی ایمیل و رمز عبور plaintext را بعداً بهصورت مستقل دوباره استفاده کنیم، بنابراین آن بررسیها را در برخی توابع جداگانه تعریف خواهیم کرد.
فایل internal/data/users.go را به صورت زیر بهروزرسانی کنید:
package data import ( "errors" "time" "greenlight.alexedwards.net/internal/validator" // New import "golang.org/x/crypto/bcrypt" ) ... func ValidateEmail(v *validator.Validator, email string) { v.Check(email != "", "email", "must be provided") v.Check(validator.Matches(email, validator.EmailRX), "email", "must be a valid email address") } func ValidatePasswordPlaintext(v *validator.Validator, password string) { v.Check(password != "", "password", "must be provided") v.Check(len(password) >= 8, "password", "must be at least 8 bytes long") v.Check(len(password) <= 72, "password", "must not be more than 72 bytes long") } func ValidateUser(v *validator.Validator, user *User) { v.Check(user.Name != "", "name", "must be provided") v.Check(len(user.Name) <= 500, "name", "must not be more than 500 bytes long") // Call the standalone ValidateEmail() helper. ValidateEmail(v, user.Email) // If the plaintext password is not nil, call the standalone // ValidatePasswordPlaintext() helper. if user.Password.plaintext != nil { ValidatePasswordPlaintext(v, *user.Password.plaintext) } // If the password hash is ever nil, this will be due to a logic error in our // codebase (probably because we forgot to set a password for the user). It's a // useful sanity check to include here, but it's not a problem with the data // provided by the client. So rather than adding an error to the validation map we // panic instead. if user.Password.hash == nil { panic("missing password hash for user") } }
ایجاد UserModel
مرحله بعدی در این فرآیند، راهاندازی یک نوع UserModel است که تعاملات دیتابیس با جدول PostgreSQL users ما را جدا میکند.
ما در اینجا از همان الگویی استفاده خواهیم کرد که برای MovieModel خود استفاده کردیم و سه متد زیر را پیادهسازی خواهیم کرد:
Insert()برای ایجاد یک رکورد کاربر جدید در دیتابیس.GetByEmail()برای بازیابی دادههای یک کاربر با آدرس ایمیل خاص.Update()برای تغییر دادههای یک کاربر خاص.
فایل internal/data/users.go را دوباره باز کنید و کد زیر را اضافه کنید:
package data import ( "context" // New import "database/sql" // New import "errors" "time" "greenlight.alexedwards.net/internal/validator" "golang.org/x/crypto/bcrypt" ) // Define a custom ErrDuplicateEmail error. var ( ErrDuplicateEmail = errors.New("duplicate email") ) ... // Create a UserModel struct which wraps the connection pool. type UserModel struct { DB *sql.DB } // Insert a new record in the database for the user. Note that the id, created_at and // version fields are all automatically generated by our database, so we use the // RETURNING clause to read them into the User struct after the insert, in the same way // that we did when creating a movie. func (m UserModel) Insert(user *User) error { query := ` INSERT INTO users (name, email, password_hash, activated) VALUES ($1, $2, $3, $4) RETURNING id, created_at, version` args := []any{user.Name, user.Email, user.Password.hash, user.Activated} ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // If the table already contains a record with this email address, then when we try // to perform the insert there will be a violation of the UNIQUE "users_email_key" // constraint that we set up in the previous chapter. We check for this error // specifically, and return a custom ErrDuplicateEmail error instead. err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt, &user.Version) if err != nil { switch { case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: return ErrDuplicateEmail default: return err } } return nil } // Retrieve the User details from the database based on the user's email address. // Because we have a UNIQUE constraint on the email column, this SQL query will only // return one record (or none at all, in which case we return an ErrRecordNotFound error). func (m UserModel) GetByEmail(email string) (*User, error) { query := ` SELECT id, created_at, name, email, password_hash, activated, version FROM users WHERE email = $1` var user User ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() err := m.DB.QueryRowContext(ctx, query, email).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 &user, nil } // Update the details for a specific user. Notice that we check against the version // field to help prevent any race conditions during the request cycle, just like we did // when updating a movie. And we also check for a violation of the "users_email_key" // constraint when performing the update, just like we did when inserting the user // record originally. func (m UserModel) Update(user *User) error { query := ` UPDATE users SET name = $1, email = $2, password_hash = $3, activated = $4, version = version + 1 WHERE id = $5 AND version = $6 RETURNING version` args := []any{ user.Name, user.Email, user.Password.hash, user.Activated, user.ID, user.Version, } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.Version) if err != nil { switch { case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: return ErrDuplicateEmail case errors.Is(err, sql.ErrNoRows): return ErrEditConflict default: return err } } return nil }
امیدواریم این بخش ساده و سرراست به نظر برسد — ما از همان الگوهای کدی استفاده میکنیم که برای عملیات CRUD روی جدول movies خود در ابتدای کتاب استفاده کردیم.
تنها تفاوت این است که در برخی متدها، ما بهطور خاص برای هرگونه خطا ناشی از نقض محدودیت یکتای users_email_key بررسی میکنیم. همانطور که در فصل بعدی خواهیم دید، با برخورد با این مورد بهعنوان یک حالت خاص، خواهیم توانست به کلاینتها با پیامی پاسخ دهیم که “این آدرس ایمیل قبلاً استفاده شده است”، بهجای ارسال پاسخ 500 Internal Server Error مانند حالت عادی.
برای تکمیل همه اینها، آخرین کاری که باید انجام دهیم بهروزرسانی فایل internal/data/models.go است تا UserModel جدید را در struct والد Models خود قرار دهیم. به صورت زیر:
package data ... type Models struct { Movies MovieModel Users UserModel // Add a new Users field. } func NewModels(db *sql.DB) Models { return Models{ Movies: MovieModel{DB: db}, Users: UserModel{DB: db}, // Initialize a new UserModel instance. } }