上一篇文章创建了token数据库表,而我们激活过程的完整性取决于一件关键的事情:发送到用户邮箱的token(称为令牌)具有“不可猜测性”。如果令牌很容易被猜到或可以被暴力破解,那么攻击者就有可能激活用户帐户,即使他们无法访问用户的邮箱。
因此,需要生成的token具有足够的随机性,不可能被猜出来。在这里我们使用Go的crypto/rand包128位(16字节)墒。如果你跟随本系列文章操作,请创建新文件internal/data/tokens.go。在接下来的几节中,这将作为所有与创建和管理tokens相关的代码文件。
$ touch internal/data/tokens.go
在文件中定义Token结构体(表示单个token包含的数据)和生成token的函数generateToken()。这里直接进入代码可以更好地说明并描述所要做的事情。
File: internal/data/tokens.go
package data
import (
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"time"
)
// 定义token使用范围常量。这里我们只有激活token,后面会增加新的用途常量。
const (
ScopeActivation = "activation"
)
// 定义Token结构体接收token数据。包括token字符串和哈希值,以及用户ID,过期时间和范围。
type Token struct {
Plaintext string
Hash []byte
UserID int64
Expiry time.Time
Scope string
}
func generateToken(userID int64, ttl time.Duration, scope string) (*Token, error) {
//创建Token实例,包含用户ID,过期时间和使用范围scope。注意使用ttl来计算过期时间。
token := &Token{
UserID: userID,
Expiry: time.Now().Add(ttl),
Scope: scope,
}
//初始化一个16字节数组
randomBytes := make([]byte, 16)
//使用crypto/rand包的Read函数来填充字节数组,随机数来自操作系统。
_, err := rand.Read(randomBytes)
if err != nil {
return nil, err
}
//将生成的字节数组转为base-32字符串并赋值给token的plaintext字段。这个字符串将
//通过邮件发送给用户。类似以下内容:
// Y3QMGX3PJ3WLRL2YRTQGQ6KRHU
//注意base-32默认会使用"="填充末尾。这里我们不需要填充,因此使用withPadding(base32.NoPadding)方法
token.Plaintext = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes)
//将token的文本内容生成SHA-256哈希。这个值将写入数据库的hash列。
hash := sha256.Sum256([]byte(token.Plaintext))
token.Hash = hash[:]
return token, nil
}
需要指出的是,我们在这里创建的纯文本token字符串(如Y3QMGX3PJ3WLRL2YRTQGQ6KRHU)不是16个字符长,而是具有16个字节的随机熵。
明文token字符串本身的长度取决于如何对这16个随机字节进行编码。在我们的例子中,我们将随机字节编码为一个base-32的字符串,这将产生一个包含26个字符的字符串。相反,如果我们使用十六进制(以16为基数)对随机字节进行编码,字符串的长度将变为32个字符。
创建数据库模型TokenModel和字段校验
下面开始设置TokenModel类型用于和数据库token表的交互。该过程和前面的MoiveModel和UserModel一样,将实现以下方法:
-
Insert()向数据库token表中插入新的token。
-
New()通过调用generateToken()函数来创建一个新的token,并调用Insert()存储数据。
-
DeleteAllForUser()删除用户特定范围的所有tokens。
我们还创建一个ValidateTokenPlaintext()函数,用于校验传入token是否为26字节。
再次打开internal/data/tokens.go文件,添加以下代码:
File:internal/data/tokens.go
package main
...
//校验plaintext是否是26字节
func ValidateTokenPlaintext(v *validator.Validator, tokenPlaintext string) {
v.Check(tokenPlaintext != "", "token", "must be provided")
v.Check(len(tokenPlaintext) == 26, "token", "must be 26 bytes long")
}
//定义TokenModel类型
type TokenModel struct {
DB *sql.DB
}
// New方法是创建Token结构体的构造方法,然后用于插入数据库tokens表
func (m TokenModel)New(userID int64, ttl time.Duration, scope string) (*Token, error) {
token, err := generateToken(userID, ttl, scope)
if err != nil {
return nil, err
}
err = m.Insert(token)
return token, err
}
// Insert()将token数据插入数据库tokens表
func (m TokenModel)Insert(token *Token) error {
query := `
INSERT INTO tokens (hash, user_id, expiry, scope)
VALUES ($1, $2, $3, $4)`
args := []interface{}{token.Hash, token.UserID, token.Expiry, token.Scope}
ctx , cancel := context.WithTimeout(context.Background(), 3 * time.Second)
defer cancel()
_, err := m.DB.ExecContext(ctx, query, args...)
return err
}
// DeleteAllForUser()删除特定用户和范围的所有tokens
func (m TokenModel)DeleteAllForUser(scope string, userID int64) error {
query := `
DELETE FROM tokens
WHERE scope = $1 AND user_id = $2`
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
defer cancel()
_, err := m.DB.ExecContext(ctx, query, scope, userID)
return err
}
最后,我们需要更新internal/data/models.go文件,将TokenModel添加到Model结构体中:
File:internal/data/models.go
package data
...
type Models struct {
Movies MovieModel
Tokens TokenModel
Users UserModel
}
func NewModels(db *sql.DB) Models {
return Models{
Movies: MovieModel{DB: db},
Tokens: TokenModel{DB: db}, //初始化TokenModel实例
Users: UserModel{DB: db},
}
}
此时启动应用程序,代码应该可以正常运行。
$ go run ./cmd/api
{"level":"INFO","time":"2022-01-03T03:01:20Z","message":"database connection pool established"}
{"level":"INFO","time":"2022-01-03T03:01:20Z","message":"starting server","properties":{"addr":":4000","env":"development"}}
附加内容
math/rand包
Go有一个math/rand包能够提供确定性伪随机数生成器(PRNG)。注意不要使用math/rand包来创建安全随机数,例如生成token和密码的时候。实际上,使用crypto/rand作为标准实践可以说是最好的。math/rand只在特定场景下使用例如,确定性随机是可接受情况,需要快速生成随机数时可用。
文章评论