美文网首页
【Go Web开发】设置用户模型

【Go Web开发】设置用户模型

作者: Go语言由浅入深 | 来源:发表于2022-03-08 22:59 被阅读0次

上一篇文章中我们的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实例
    }
}

相关文章

网友评论

      本文标题:【Go Web开发】设置用户模型

      本文链接:https://www.haomeiwen.com/subject/qnszrrtx.html