如何安全地存储密码?
大家好,欢迎回到后端大师班!
在本讲座中,我们将学习如何安全地将用户密码存储在数据库中。
以下是:
- YouTube 上完整系列播放列表的链接
- 以及它的Github 仓库
如何存储密码
你已经知道,我们永远不应该存储裸露的密码!所以我们的想法是先对它进行哈希处理,然后只存储该哈希值。
基本上,密码将使用brypt
散列函数进行散列以产生散列值。
除了输入密码之外,bcrypt
还需要一个cost
参数,该参数将决定密钥扩展轮数或算法的迭代次数。
Bcrypt
还会生成一个随机数salt
用于迭代,这将有助于抵御彩虹表攻击。由于这个随机数的存在salt
,即使输入相同的密码,算法也会给出完全不同的输出哈希值。
和cost
也salt
将被添加到哈希中以生成最终的哈希字符串,其看起来像这样:
此哈希字符串包含 4 个组成部分:
- 第一部分是
hash algorithm identifier
。2A
是算法的标识符bcrypt
。 - 第二部分是
cost
。在这种情况下,成本是10
,这意味着将进行2^10 = 1024
多轮密钥扩展。 - 第三部分是
salt
长度为 的16 bytes
,即128 bits
。它使用base64
format 进行编码,生成一串22
字符。 - 最后,最后一部分是
24 bytes
哈希值,编码为31
字符。
所有这 4 个部分连接在一起形成一个哈希字符串,它是我们将存储在数据库中的字符串。
这就是散列用户密码的过程!
但是当用户登录时,我们如何验证他们输入的密码是否正确?
好吧,首先我们必须找到hashed_password
存储在数据库中的username
。
然后,我们使用该cost
和作为参数,对刚刚输入的用户进行哈希运算。此操作的输出将是另一个哈希值。salt
hashed_password
naked_password
bcrypt
接下来我们要做的就是比较这两个哈希值。如果它们相同,则密码正确。
好了,现在我们来看看如何实现这些逻辑Golang
。
实现散列和比较密码的函数
在上一讲中,我们生成了在数据库中创建新用户的代码。hashed_password
是函数的输入参数之一CreateUser()
。
type CreateUserParams struct {
Username string `json:"username"`
HashedPassword string `json:"hashed_password"`
FullName string `json:"full_name"`
Email string `json:"email"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRowContext(ctx, createUser,
arg.Username,
arg.HashedPassword,
arg.FullName,
arg.Email,
)
var i User
err := row.Scan(
&i.Username,
&i.HashedPassword,
&i.FullName,
&i.Email,
&i.PasswordChangedAt,
&i.CreatedAt,
)
return i, err
}
此外,在createRandomUser()
单元测试的这个函数中db/sqlc/user_test.go
,我们"secret"
对字段使用了一个简单的字符串hash_password
,这并不能反映该字段应该保存的真正正确值。
func createRandomUser(t *testing.T) User {
arg := CreateUserParams{
Username: util.RandomOwner(),
HashedPassword: "secret",
FullName: util.RandomOwner(),
Email: util.RandomEmail(),
}
...
}
所以今天我们要更新它以使用真正的哈希字符串。
哈希密码函数
首先,我们在包password.go
中创建一个新文件util
。在这个文件中,我将定义一个新函数HashPassword()
:
它将以password
字符串作为输入,并返回一个string
或error
。此函数将计算bcrypt
输入的哈希字符串password
。
// HashPassword returns the bcrypt hash of the password
func HashPassword(password string) (string, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("failed to hash password: %w", err)
}
return string(hashedPassword), nil
}
在这个函数中,我们调用bcrypt.GenerateFromPassword()
。它需要2个输入参数: slicepassword
类型的[]byte
,以及cost
类型的int
。
所以我们必须将输入转换password
为string
切片[]byte
。
对于cost
,我使用的bcrypt.DefaultCost
值是10
。
此函数的输出将是hashedPassword
和error
。如果error
是not nil
,那么我们只返回一个空的散列字符串,并error
用一条消息包装 ,内容为:"failed to hash password"
。
否则,我们将hashedPassword
from[]byte
切片转换为string
,并返回错误nil
。
比较密码功能
接下来,我们将编写另一个函数来检查密码是否正确:CheckPassword()
。
此函数将接受两个输入参数:一个password
用于检查的 ,以及一个hashedPassword
用于比较的 。它将返回一个error
作为输出。
基本上,该函数将检查输入password
与提供的内容是否正确hashedPassword
。
由于标准bcrypt
包已经实现了这个功能,我们所要做的就是调用函数,并将和 naked转换为切片后bcrypt.CompareHashAndPassword()
传入。hashedPassword
password
string
[]byte
// CheckPassword checks if the provided password is correct or not
func CheckPassword(password string, hashedPassword string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
}
就这样。我们完成了!
为 HashPassword 和 CheckPassword 函数编写单元测试
现在让我们编写一些单元测试来确保这两个函数按预期工作。
我将password_test.go
在util
包中创建一个新文件。然后让我们定义TestPassword()
一个以testing.T
对象作为输入的函数。
首先,我将生成一个新的password
随机字符串6
。然后,我们通过hashedPassword
调用HashPassword()
生成的密码函数来获取。
我们要求不返回任何错误,并且hashedPassword
字符串不应为空。
func TestPassword(t *testing.T) {
password := RandomString(6)
hashedPassword, err := HashPassword(password)
require.NoError(t, err)
require.NotEmpty(t, hashedPassword)
err = CheckPassword(password, hashedPassword1)
require.NoError(t, err)
}
接下来我们CheckPassword()
用password
和hashedPassword
参数调用函数。
password
因为这与我们用来创建的相同hashedPassword
,所以该函数应该不会返回任何错误,这意味着密码正确。
我们还来测试一下提供的情况incorrect password
!
我将生成一个新的随机wrongPassword
字符串,并CheckPassword()
使用这个wrongPassword
参数再次调用。这一次,我们期望error
返回一个,因为提供的密码不正确。
func TestPassword(t *testing.T) {
password := RandomString(6)
hashedPassword, err := HashPassword(password)
require.NoError(t, err)
require.NotEmpty(t, hashedPassword)
wrongPassword := RandomString(6)
err = CheckPassword(wrongPassword, hashedPassword)
require.EqualError(t, err, bcrypt.ErrMismatchedHashAndPassword.Error())
}
确切地说,我们用它来require.EqualError()
比较输出误差。它必须等于bcrypt.ErrMismatchedHashAndPassword
误差。
好的,测试完成了。让我们运行它吧!
通过了!太棒了!
更新现有代码以使用 HashPassword 函数
看来HashPassword()
函数正常工作了。让我们回到user_test.go
文件并在函数中使用它createRandomUser()
。
在这里,我将通过调用带有随机字符串的函数来创建一个新hashedPassword
值。util.HashPassword()
6
我们要求没有错误,然后"secret"
将常量改为hashedPassword
:
func createRandomUser(t *testing.T) User {
hashedPassword, err := util.HashPassword(util.RandomString(6))
require.NoError(t, err)
arg := CreateUserParams{
Username: util.RandomOwner(),
HashedPassword: hashedPassword,
FullName: util.RandomOwner(),
Email: util.RandomEmail(),
}
...
}
好了,我们来运行整个db
包测试吧!
全部通过了!
现在,如果我们在 Table Plus 中打开数据库并检查用户表,我们可以看到该hashed_password
列现在包含正确的bcrypt
散列字符串。
它看起来就像我在此视频开头向您展示的示例。
确保所有哈希密码都不同
我们要确保的一件事是:如果same password
被散列两次,2 different hash values
就应该被生成。
让我们回到TestPassword()
函数。我要把hashPassword
变量名改成hashedPassword1
。
那么我们复制一下哈希密码代码块,并将变量名改为hashedPassword2
。
func TestPassword(t *testing.T) {
password := RandomString(6)
hashedPassword1, err := HashPassword(password)
require.NoError(t, err)
require.NotEmpty(t, hashedPassword1)
err = CheckPassword(password, hashedPassword1)
require.NoError(t, err)
wrongPassword := RandomString(6)
err = CheckPassword(wrongPassword, hashedPassword1)
require.EqualError(t, err, bcrypt.ErrMismatchedHashAndPassword.Error())
hashedPassword2, err := HashPassword(password)
require.NoError(t, err)
require.NotEmpty(t, hashedPassword2)
require.NotEqual(t, hashedPassword1, hashedPassword2)
}
我们期望看到的是:的值hashedPassword2
应该与的值不同hashedPassword1
。所以这里我使用require.NotEqual()
来检查这个条件。
好的,让我们重新运行测试。
通过了!太棒了!
为了真正理解它为什么通过,我们必须打开该bcrypt.GenerateFromPassword()
函数的实现。
func GenerateFromPassword(password []byte, cost int) ([]byte, error) {
p, err := newFromPassword(password, cost)
if err != nil {
return nil, err
}
return p.Hash(), nil
}
func newFromPassword(password []byte, cost int) (*hashed, error) {
if cost < MinCost {
cost = DefaultCost
}
p := new(hashed)
p.major = majorVersion
p.minor = minorVersion
err := checkCost(cost)
if err != nil {
return nil, err
}
p.cost = cost
unencodedSalt := make([]byte, maxSaltSize)
_, err = io.ReadFull(rand.Reader, unencodedSalt)
if err != nil {
return nil, err
}
p.salt = base64Encode(unencodedSalt)
hash, err := bcrypt(password, p.cost, p.salt)
if err != nil {
return nil, err
}
p.hash = hash
return p, err
}
正如您在此处看到的,在newFromPassword()
函数中,random salt
生成了一个值,并在函数中使用它bcrypt()
来生成哈希。
所以现在你知道了,由于这个原因random salt
,每次生成的哈希值都会不同。
实现创建用户 API
下一步,我将使用HashPassword()
我们编写的函数来实现create user API
我们的简单银行。
让我们在包user.go
内创建一个新文件api
。
create account API
这个 API与我们之前实现的非常相似,所以我只需从api/account.go
文件中复制它。
那么我们将其struct
改为createUserRequest
。
第一个参数是username
。它是一个required
字段。
假设我们不允许它包含任何类型的特殊字符,所以这里我将使用验证器包alphanum
已经提供的标签。它的基本意思是这个字段应该只包含 ASCII 字母数字字符。
第二个字段是password
。它也是required
。通常我们不希望密码太短,因为太短很容易被破解。所以这里我们使用min
标签 来表示密码长度至少为6
个字符。
type createUserRequest struct {
Username string `json:"username" binding:"required,alphanum"`
Password string `json:"password" binding:"required,min=6"`
FullName string `json:"full_name" binding:"required"`
Email string `json:"email" binding:"required,email"`
}
第三个字段是full_name
用户的。此字段没有特定要求,只需为 即可required
。
然后,最后一个字段是email
,它非常重要,因为它是用户和我们系统之间的主要沟通渠道。我们可以使用email
validator 包提供的标签来确保此字段的值是正确的电子邮件地址。
验证器包已经实现了许多其他有用的内置标签,您可以在其文档或github 页面中查看它们。
现在我们回到代码中来完成这个createUser()
功能。
func (server *Server) createUser(ctx *gin.Context) {
var req createUserRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, errorResponse(err))
return
}
hashedPassword, err := util.HashPassword(req.Password)
if err != nil {
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
arg := db.CreateUserParams{
Username: req.Username,
HashedPassword: hashedPassword,
FullName: req.FullName,
Email: req.Email,
}
...
}
这里我们使用ctx.ShouldBindJSON()
函数将输入参数绑定到对象context
中createUserRequest
。
如果任何参数无效,我们仅将400 Bad Request
状态返回给客户端。否则,我们将使用它们来构建db.CreateUserParams
对象。
有 4个字段需要设置:Username
、、、和。HashedPassword
Fullname
Email
因此首先,我们hashedPassword
通过调用util.HashPassword()
函数并传入输入request.Password
值来计算。
如果此函数返回not nil
错误,那么我们只需500 Internal Server Error
向客户端返回一个状态。
否则,我们将构建CreateUserParams
对象,其中Username
是request.Username
,HashedPassword
是计算的hashedPassword
,FullName
是request.FullName
,Email
是request.Email
。
func (server *Server) createUser(ctx *gin.Context) {
...
user, err := server.store.CreateUser(ctx, arg)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok {
switch pqErr.Code.Name() {
case "unique_violation":
ctx.JSON(http.StatusForbidden, errorResponse(err))
return
}
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
ctx.JSON(http.StatusOK, user)
}
然后我们server.store.CreateUser()
用这个输入参数调用。它将返回创建的user
对象或一个error
。
就像在 中一样create account API
,如果 error 不为 nil,则可能出现以下几种情况。请记住,在 users 表中,我们有 2 个unique constraints
:
- 一个是主键
username
, - 另一个用于
email
列。
我们在这个表中没有foreign key
,所以这里我们只需要保留unique_violation
代码名称来返回状态,403 Forbidden
以防有相同username
或email
已经存在的用户。
最后,如果没有发生错误,我们只需将200 OK
创建的状态返回user
给客户端。
好的,现在createUser
API 处理程序已完成。我们要做的最后一步是在api/server.go
文件中为其注册一个路由。
在这个NewServer()
函数中,我将添加一个带有方法的新路由POST
。它的路径应该是/users
,它的处理函数是server.createUser
// NewServer creates a new HTTP server and set up routing.
func NewServer(store db.Store) *Server {
server := &Server{store: store}
router := gin.Default()
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("currency", validCurrency)
}
router.POST("/users", server.createUser)
router.POST("/accounts", server.createAccount)
router.GET("/accounts/:id", server.getAccount)
router.GET("/accounts", server.listAccounts)
router.POST("/transfers", server.createTransfer)
server.router = router
return server
}
就这样!我们完成了!
测试创建用户 API
让我们打开终端并运行make server
来启动服务器。
我将使用Postman来测试新的 API。
我们选择方法POST
并填写URL:http://localhost:8080/users
对于请求主体,我们选择raw
,并选择JSON
格式。我将使用以下 JSON 数据:
{
"username": "quang1",
"full_name": "Quang Pham",
"email": "quang@email.com",
"password": "secret"
}
好的,让我们发送这个请求!
成功了!我们得到了创建的user
对象,并且所有字段值都正确。
让我们打开数据库来查找这个用户。
就是这样!所以,我们的 API 在理想情况下运行良好。
现在让我们看看如果我第二次发送相同的请求会发生什么。
我们得到了一个403 Forbidden
状态码。原因是unique constraint
for 语句username
被违反了。
我们正在尝试创建具有相同权限的另一个用户username
,因此显然不应该允许!
现在让我们尝试将更改为username
,quang2
但保持email
值相同,然后再次发送请求。
我们仍然得到了403 Forbidden
。但这次错误是因为email
违反了唯一约束。正如我们所料!
如果我将更改为email
,quang2@email.com
那么请求将会成功,因为该电子邮件不属于任何其他用户。
好的,现在让我们尝试一个无效的username
,例如quang#2
:
这次的状态码是400 Bad Request
。原因是:标签username
上的 字段验证失败。中alphanum
有一个特殊字符,不是字母数字。#
username
接下来,我们尝试一个无效的邮箱地址。我要将 改为username
,quang3
并将email
改为quang3email.com
,去掉@
字符。
我们400 Bad Request
又拿到了状态。错误信息是:标签字段验证email
失败email
,这正是我们想要的。
好的,现在让我们修复email
地址,并将 改为password
一个非常短的值,例如"123"
。然后再次发送请求。
这次我们遇到了错误,因为password
标签的字段验证失败了min
。它不满足6
字符的最小长度限制。
API 不应暴露散列密码
在结束之前,我还有一件事要告诉你。让我们修复该password
值并再次发送请求。
现在成功了。但是你会注意到,这个hashed_password
值也被返回了,这看起来不对劲,因为客户端永远不需要用到这个值。
而且,由于这条敏感信息正在公开传播,这可能会引起一些安全担忧。
最好从响应主体中删除该字段。
为此,我将createUserResponse struct
在api/user.go
文件中声明一个 new 。它将包含db.User
结构体的几乎所有字段,除了HashedPassword
应该删除的字段。
type createUserResponse struct {
Username string `json:"username"`
FullName string `json:"full_name"`
Email string `json:"email"`
PasswordChangedAt time.Time `json:"password_changed_at"`
CreatedAt time.Time `json:"created_at"`
}
然后在这里,在处理函数的末尾createUser()
,我们构建一个新createUserResponse
对象,其中Username
是user.Username
,FullName
是user.FullName
,Email
是user.Email
,PasswordChangedAt
是user.PasswordChangedAt
,和CreatedAt
是user.CreatedAt
。
func (server *Server) createUser(ctx *gin.Context) {
...
user, err := server.store.CreateUser(ctx, arg)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok {
switch pqErr.Code.Name() {
case "unique_violation":
ctx.JSON(http.StatusForbidden, errorResponse(err))
return
}
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
rsp := createUserResponse{
Username: user.Username,
FullName: user.FullName,
Email: user.Email,
PasswordChangedAt: user.PasswordChangedAt,
CreatedAt: user.CreatedAt,
}
ctx.JSON(http.StatusOK, rsp)
}
最后,我们返回response
对象而不是user
。完成了!
让我们重启服务器。然后返回 Postman,将username
和更新email
为新值,并发送请求。
成功了。现在hashed_password
响应主体中没有任何字段了。完美!
本次讲座到此结束。希望大家学到了一些有用的知识。
感谢您的阅读,下期再见!
如果您喜欢这篇文章,请订阅我们的Youtube 频道并在Twitter或Facebook上关注我们,以便将来获取更多教程。
如果你想加入我目前在 Voodoo 的优秀团队,请查看我们的职位空缺。你可以远程办公,也可以在巴黎/阿姆斯特丹/伦敦/柏林/巴塞罗那现场办公,但需获得签证担保。
文章来源:https://dev.to/techschoolguru/how-to-securely-store-passwords-3cg7