如何安全地存储密码?

2025-06-07

如何安全地存储密码?

大家好,欢迎回到后端大师班!

在本讲座中,我们将学习如何安全地将用户密码存储在数据库中。

以下是:

如何存储密码

你已经知道,我们永远不应该存储裸露的密码!所以我们的想法是先对它进行哈希处理,然后只存储该哈希值。

基本上,密码将使用brypt散列函数进行散列以产生散列值。

除了输入密码之外,bcrypt还需要一个cost参数,该参数将决定密钥扩展轮数或算法的迭代次数。

Bcrypt还会生成一个随机数salt用于迭代,这将有助于抵御彩虹表攻击。由于这个随机数的存在salt,即使输入相同的密码,算法也会给出完全不同的输出哈希值。

costsalt将被添加到哈希中以生成最终的哈希字符串,其看起来像这样:

替代文本

此哈希字符串包含 4 个组成部分:

  • 第一部分是hash algorithm identifier2A是算法的标识符bcrypt
  • 第二部分是cost。在这种情况下,成本是10,这意味着将进行2^10 = 1024多轮密钥扩展。
  • 第三部分是salt长度为 的16 bytes,即128 bits。它使用base64format 进行编码,生成一串22字符。
  • 最后,最后一部分是24 bytes哈希值,编码为31字符。

所有这 4 个部分连接在一起形成一个哈希字符串,它是我们将存储在数据库中的字符串。

替代文本

这就是散列用户密码的过程!

但是当用户登录时,我们如何验证他们输入的密码是否正确?

好吧,首先我们必须找到hashed_password存储在数据库中的username

然后,我们使用cost作为参数,对刚刚输入的用户进行哈希运算。此操作的输出将是另一个哈希值。salthashed_passwordnaked_passwordbcrypt

接下来我们要做的就是比较这两个哈希值。如果它们相同,则密码正确。

替代文本

好了,现在我们来看看如何实现这些逻辑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
}


Enter fullscreen mode Exit fullscreen mode

此外,在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(),
    }

    ...
}


Enter fullscreen mode Exit fullscreen mode

所以今天我们要更新它以使用真正的哈希字符串。

哈希密码函数

首先,我们在包password.go中创建一个新文件util。在这个文件中,我将定义一个新函数HashPassword()

它将以password字符串作为输入,并返回一个stringerror。此函数将计算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
}


Enter fullscreen mode Exit fullscreen mode

在这个函数中,我们调用bcrypt.GenerateFromPassword()。它需要2个输入参数: slicepassword类型的[]byte,以及cost类型的int

所以我们必须将输入转换passwordstring切片[]byte

对于cost,我使用的bcrypt.DefaultCost值是10

此函数的输出将是hashedPassworderror。如果errornot nil,那么我们只返回一个空的散列字符串,并error用一条消息包装 ,内容为:"failed to hash password"

否则,我们将hashedPasswordfrom[]byte切片转换为string,并返回错误nil

比较密码功能

接下来,我们将编写另一个函数来检查密码是否正确:CheckPassword()

此函数将接受两个输入参数:一个password用于检查的 ,以及一个hashedPassword用于比较的 。它将返回一个error作为输出。

基本上,该函数将检查输入password与提供的内容是否正确hashedPassword

由于标准bcrypt包已经实现了这个功能,我们所要做的就是调用函数,并将和 naked转换为切片后bcrypt.CompareHashAndPassword()传入hashedPasswordpasswordstring[]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))
}


Enter fullscreen mode Exit fullscreen mode

就这样。我们完成了!

为 HashPassword 和 CheckPassword 函数编写单元测试

现在让我们编写一些单元测试来确保这两个函数按预期工作。

我将password_test.goutil包中创建一个新文件。然后让我们定义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)
}


Enter fullscreen mode Exit fullscreen mode

接下来我们CheckPassword()passwordhashedPassword参数调用函数。

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())
}


Enter fullscreen mode Exit fullscreen mode

确切地说,我们用它来require.EqualError()比较输出误差。它必须等于bcrypt.ErrMismatchedHashAndPassword误差。

好的,测试完成了。让我们运行它吧!

替代文本

通过了!太棒了!

更新现有代码以使用 HashPassword 函数

看来HashPassword()函数正常工作了。让我们回到user_test.go文件并在函数中使用它createRandomUser()

在这里,我将通过调用带有随机字符串的函数来创建一个新hashedPasswordutil.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(),
    }

    ...
}


Enter fullscreen mode Exit fullscreen mode

好了,我们来运行整个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)
}


Enter fullscreen mode Exit fullscreen mode

我们期望看到的是:的值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
}


Enter fullscreen mode Exit fullscreen mode

正如您在此处看到的,在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"`
}


Enter fullscreen mode Exit fullscreen mode

第三个字段是full_name用户的。此字段没有特定要求,只需为 即可required

然后,最后一个字段是email,它非常重要,因为它是用户和我们系统之间的主要沟通渠道。我们可以使用emailvalidator 包提供的标签来确保此字段的值是正确的电子邮件地址。

验证器包已经实现了许多其他有用的内置标签,您可以在其文档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,
    }

    ...   
}


Enter fullscreen mode Exit fullscreen mode

这里我们使用ctx.ShouldBindJSON()函数将输入参数绑定到对象contextcreateUserRequest

如果任何参数无效,我们仅将400 Bad Request状态返回给客户端。否则,我们将使用它们来构建db.CreateUserParams对象。

有 4个字段需要设置:Username、、、HashedPasswordFullnameEmail

因此首先,我们hashedPassword通过调用util.HashPassword()函数并传入输入request.Password值来计算。

如果此函数返回not nil错误,那么我们只需500 Internal Server Error向客户端返回一个状态。

否则,我们将构建CreateUserParams对象,其中Usernamerequest.UsernameHashedPassword是计算的hashedPasswordFullNamerequest.FullNameEmailrequest.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)
}


Enter fullscreen mode Exit fullscreen mode

然后我们server.store.CreateUser()用这个输入参数调用。它将返回创建的user对象或一个error

就像在 中一样create account API,如果 error 不为 nil,则可能出现以下几种情况。请记住,在 users 表中,我们有 2 个unique constraints

  • 一个是主键username
  • 另一个用于email列。

我们在这个表中没有foreign key,所以这里我们只需要保留unique_violation代码名称来返回状态,403 Forbidden以防有相同usernameemail已经存在的用户。

最后,如果没有发生错误,我们只需将200 OK创建的状态返回user给客户端。

好的,现在createUserAPI 处理程序已完成。我们要做的最后一步是在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
}


Enter fullscreen mode Exit fullscreen mode

就这样!我们完成了!

测试创建用户 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"
}


Enter fullscreen mode Exit fullscreen mode

好的,让我们发送这个请求!

替代文本

成功了!我们得到了创建的user对象,并且所有字段值都正确。

让我们打开数据库来查找这个用户。

替代文本

就是这样!所以,我们的 API 在理想情况下运行良好。

现在让我们看看如果我第二次发送相同的请求会发生什么。

替代文本

我们得到了一个403 Forbidden状态码。原因是unique constraintfor 语句username被违反了。

我们正在尝试创建具有相同权限的另一个用户username,因此显然不应该允许!

现在让我们尝试将更改为usernamequang2但保持email值相同,然后再次发送请求。

替代文本

我们仍然得到了403 Forbidden。但这次错误是因为email违反了唯一约束。正如我们所料!

如果我将更改为emailquang2@email.com那么请求将会成功,因为该电子邮件不属于任何其他用户。

替代文本

好的,现在让我们尝试一个无效的username,例如quang#2

替代文本

这次的状态码是400 Bad Request。原因是:标签username上的 字段验证失败。alphanum有一个特殊字符,不是字母数字。#username

接下来,我们尝试一个无效的邮箱地址。我要将 改为usernamequang3并将email改为quang3email.com,去掉@字符。

替代文本

我们400 Bad Request又拿到了状态。错误信息是:标签字段验证email失败email,这正是我们想要的。

好的,现在让我们修复email地址,并将 改为password一个非常短的值,例如"123"。然后再次发送请求。

替代文本

这次我们遇到了错误,因为password标签的字段验证失败了min。它不满足6字符的最小长度限制。

API 不应暴露散列密码

在结束之前,我还有一件事要告诉你。让我们修复该password值并再次发送请求。

替代文本

现在成功了。但是你会注意到,这个hashed_password值也被返回了,这看起来不对劲,因为客户端永远不需要用到这个值。

而且,由于这条敏感信息正在公开传播,这可能会引起一些安全担忧。

最好从响应主体中删除该字段。

为此,我将createUserResponse structapi/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"`
}


Enter fullscreen mode Exit fullscreen mode

然后在这里,在处理函数的末尾createUser(),我们构建一个新createUserResponse对象,其中Usernameuser.UsernameFullNameuser.FullNameEmailuser.EmailPasswordChangedAtuser.PasswordChangedAt,和CreatedAtuser.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)
}


Enter fullscreen mode Exit fullscreen mode

最后,我们返回response对象而不是user。完成了!

让我们重启服务器。然后返回 Postman,将username和更新email为新值,并发送请求。

替代文本

成功了。现在hashed_password响应主体中没有任何字段了。完美!

本次讲座到此结束。希望大家学到了一些有用的知识。

感谢您的阅读,下期再见!


如果您喜欢这篇文章,请订阅我们的Youtube 频道并在TwitterFacebook上关注我们,以便将来获取更多教程。


如果你想加入我目前在 Voodoo 的优秀团队,请查看我们的职位空缺。你可以远程办公,也可以在巴黎/阿姆斯特丹/伦敦/柏林/巴塞罗那现场办公,但需获得签证担保。

文章来源:https://dev.to/techschoolguru/how-to-securely-store-passwords-3cg7
PREV
使用 Gin 在 Go 中实现 RESTful HTTP API
NEXT
如何创建和签署 SSL/TLS 证书