在上一篇文章中我们的user数据库表已经设置好了,接下来将更新internal/data包,新增一个User结构体(表示单个用户的数据),并创建UserModel类型(将使用它对用户表执行各种SQL查询)。
如果你跟随本系列文章的操作,请创建internal/data/user.go文件,添加如下代码:
$ touch internal/data/user.go
先从定义User结构体开始,并创建几个帮助函数来设置和验证用户密码。我们前面提到过,在本项目中使用哈希处理密码后再写入数据库。因此需要先下载golang.org/x/crypto/bcrypt包,它提供了一个GO实现的、易于使用的哈希算法。
$ go get golang.org/x/crypto/bcrypt@latest
go: downloading golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b
go get: added golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b
然后在internal/data/usr.go文件中,创建User结构体和帮助函数:
File:internal/data/users.go
package data
import (
"context"
"database/sql"
"errors"
"golang.org/x/crypto/bcrypt"
)
//定义User结构体,存储单个用户信息。注意json:"-"标签,防止password和version序列化,而且自定义password类型
type User struct {
ID int64 `json:"id"`
CreateAt time.Time `json:"create_at"`
Name string `json:"name"`
Email string `json:"email"`
Password password `json:"-"`
Activated bool `json:"activated"`
Version int `json:"-"`
}
//创建自定义password类型,存放纯文本和哈希后的密码。plaintext用指针类型方便判断是否为空。
type password struct {
plaintext *string
hash []byte
}
// Set()方法计算密码哈希值,并存放到password结构体中
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
}
//Matches()方法检查提供的纯文本密码是否与哈希值匹配。
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/brcypt包是如何工作的:
- bcrypt.GenerateFromPassword()函数使用特定的参数(上面的12)来生成哈希值。参数值越大,生成的哈希值越复杂但是效率越低。这里有一个平衡点——我们的目标是让攻击者难以破解,但同时又不至于慢到影响到API的用户体验。这个函数返回一个哈希字符串,格式如下:
$2b$[cost]$[22-character salt][31-character hash]
- bcrypt.CompareHashAndPassword()函数根据提供的密码文本使用特定参数和盐(一种加密方法)重新做哈希计算并和用户已经存在的hash值对比。这里使用的是subtle.ConstantTimeCompare()函数,它以常数时间执行比较(以降低定时攻击的风险)。如果不匹配,会返回brypt.ErrMismatchedHashAndPassword错误。
添加校验
继续为User结构体创建一些字段检查。具体来说,我们想:
- 校验用户Name字段是否不为空,长度不能超过500字节。
- 检查Email字段不为空,而且格式正确,用书中前面的定义的正则表达式来验证。
- 如果Password.plaintext字段不是nil,检查其值是否为空字符串并且长度为8到72字节。
- 检查Password.hash字段不能为nil。
注意:前面用的哈希计算函数输入不会超过72字节,如果用户密码很长的话就截取到72字节,其他都忽略。为了避免给用户造成任何困惑,我们在验证检查中强制密码的最大长度为72字节。如果不想强制限制最大长度,则可以对密码进行预哈希处理。
此外,我们还将在本书后面单独使用电子邮件和明文密码验证,因此我们将在一些单独的函数中定义这些检查。
更新internal/data/users.go文件:
package data
...
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")
//验证email格式
ValidateEmail(v, user.Email)
//如果密码文本不为nil,调用帮助函数进行校验
if user.Password.plaintext != nil {
ValidatePasswordPlaintext(v, *user.Password.plaintext)
}
if user.Password.hash == nil {
panic("missing password hash for user")
}
}
创建用户模型
接下来创建UserModel类型它隔离了数据库与PostgreSQL用户表的交互。这个过程和MovieModel类似,将实现以下三个方法:
- Insert()在数据库中创建新的用户。
- GetByEmail()根据用户email地址查询用户信息。
- Update()更新用户信息。
打开internal/data/user.go文件,添加以下代码:
package data
import (
"context"
"database/sql"
"errors"
"golang.org/x/crypto/bcrypt"
"greenlight.alexedwards.net/internal/validator"
"time"
)
var (
ErrDuplicateEmail = errors.New("duplicate email")
)
...
//创建UserModel结构体封装数据库连接池
type UserModel struct {
DB *sql.DB
}
//向数据库中添加用户信息,create_at和version都是数据库自动生成的。
func (m UserModel) Insert(user *User) error {
query := `
INSERT INTO users (name, email, password_hash, activated)
VALUES ($1, $2, $3, $4)
RETURNING id, create_at, version`
args := []interface{}{user.Name, user.Email, user.Password.hash, user.Activated}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
//如果数据库中已经存在email地址,就会报UNIQUE "users_email_key"约束,我们需要检查该错误。
err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreateAt, &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
}
//根据email地址,查询用户信息。因为email是唯一的因此查询最多只有一条数据。
func (m UserModel) GetByEmail(email string) (*User, error) {
query := `
SELECT id, create_at, name, email, password_hash, activate, 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.CreateAt,
&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
}
//更新用户信息,注意检查version避免竞争条件。同时检查email值的唯一性。
func (m UserModel) Update(user *User) error {
query := `
UPDATE users
SET name = $1, email = $2, password_hash = $3, activate = $4, version = version + 1
WHERE id = $5 AND version = $6
RETURNING version`
args := []interface{}{
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
}
希望上面的代码看起来还不错并且很直观,这里使用的模式和前面movie的增删改查是一样的。唯一的区别就是增加了user_email_key的一些特殊错误检查,因为它的值都是不同的。我们在后面会看到,email的特殊情况可以给用户提供特定错误返回,例如:“此邮箱已经存在“,而不是返回500内部错误。
为了完成整个接口功能,最后需要更新internal/data/models.go文件,将UserModel添加到Models结构体中:
File: internal/data/models.go
package data
...
type Models struct {
Movies MovieModel
Users UserModel
}
func NewModels(db *sql.DB) Models {
return Models{
Movies: MovieModel{DB: db},
Users: UserModel{DB: db}, //初始化UserModel实例
}
}
网友评论