学习和构建 Web 身份验证系统(通用原则)
什么是身份验证?
密码
哈希
亲自动手
用户模型
注册流程
登录流程
Web 服务器是无状态的
什么是身份验证?
服务器本质上就是些愚蠢的计算机程序,它们在处理一次请求后,根本无法记住是谁发出的请求以及是什么请求。客户端和服务器之间通过 HTTP 模型进行的通信是无状态的,这意味着服务器无法在未经某种身份验证的情况下确认每个请求的客户端(用户代理)身份。此外,服务器还承担着过重的责任,例如不向他人显示你的个人 Twitter 私信、记住你在亚马逊购物车中添加的商品、保护你在 DEV 文章上的草稿不被他人复制、防止你入侵前任的 Facebook 账号。所有这些都需要一种方法来告知服务器你是谁以及应该为你提供什么服务。
身份验证不仅仅是存储“用户邮箱地址”。邮箱地址和用户名是您在网络上为自己创建的公开的数字身份。其他人也可以查看和使用它。因此,我们还使用密码和令牌来保护您的非公开资源。
密码
存储密码最基本、最不安全的方法就是直接保存。保存后,您需要查询并将其与用户提供的密码进行匹配。这种方法极其糟糕,因为密码可能通过网络被盗,甚至数据库被黑客入侵。大多数人会在多个服务中使用相同的密码,您很可能会将所有密码暴露给所有在您网站上注册的用户。
“以纯文本形式存储密码是一种罪过。 ” - J ✝️
一种方法是加密密码然后存储它。

- 您选择一个密钥,并将其与密码混合,使用算法生成随机字符串。
- 该密码(乱码)将存储在数据库中。
- 身份验证时:
- 您可以使用相同的密钥从数据库中解密密码来生成一个值并将其与用户提供的密码进行匹配。
- 或者您可以使用相同的密钥加密输入的密码,并将其与存储在数据库中的值进行匹配。
有许多加密算法可以作为Go库使用。您也可以在其他您选择的语言中找到相同的算法。这种方法的缺点是,如果您可以将密码解密为原始文本,那么黑客也可以。如果他们能够猜出密钥,那么您数据库中的所有其他用户也会受到威胁。
哈希
使用哈希函数可以实现在不解密的情况下比较密码进行身份验证。哈希函数使用一些预定义的算法将随机长度的字符串转换为固定长度的字符串。

- 与加密不同,散列函数生成的文本是不可逆的。
- 对于可变长度的输入,输出将是固定长度。
- 即使输入文本发生很小的变化,也会生成完全不同的哈希值。
- 对于相同的输入,会生成相同的哈希值。我们可以使用“盐和胡椒”来防止这种情况。
盐和胡椒
在将密码传入哈希函数之前,我们需要添加一些字节。由于哈希值无法解密,但有人仍然可以生成一个彩虹表,该表是预先计算好的常用密码及其哈希函数的表格。黑客可以将哈希值与数据库哈希值进行匹配,从而获取密码。如果在保存哈希值之前,在密码中添加一个唯一的随机字符串,就可以防止这种情况发生。saltedhash(password) = hash(password || salt)
- 每个密码的盐值都是唯一的。因此,所有哈希值都是唯一的。
- 盐不是私有实体,它可以与哈希一起保存为哈希的一部分或保存在不同的字段中。
- 如果两个用户使用相同的密码,则添加盐后,他们生成的哈希值会有所不同。
Pepper也是添加到密码中的随机字符串,它们与盐值不同,因为它们并非每个用户都独有,而是在所有应用程序中都相同。它们不一定存储在数据库中。我们将在应用程序演示中将它们用作环境变量。
亲自动手
- 注册在线免费postgres 数据库服务并获得
host, port, username, dbname and password
。 - 从此处的 github 分叉并克隆该项目。
- 编辑数据库凭据(或使用提供的)。
- 在项目根目录中运行
go run main.go
。 - 该项目包含主页、登录、注册、个人资料和账户页面。要导航到个人资料和账户页面,您需要一个令牌(稍后解释)。
- 每次服务器重启,数据库都会重置。您可以在根目录
setUpDB
下注释掉相关代码main.go
。
要理解散列的应用,我们首先需要有密码和密码散列之类的字段。
用户模型
gorm 标签 (
gorm:"-"
) 忽略了密码字段,因为我们从不在数据库中存储密码。我们会存储明确定义的密码哈希值。
注册流程
用于bcrypt.GenerateFromPassword(password, cost)
获取密码的哈希值。第二个参数是成本,即计算密码哈希值所需的工作量。随着计算机性能的提升,成本值将来可能会有所变化。目前默认成本为 10。
// Create adds a new user to db | |
func Create(w http.ResponseWriter, req *http.Request) { | |
form := new(Form) | |
// get password, email into form struct | |
utils.ParseForm(form, req) | |
// Add pepper string to password | |
pwdWithPepper := form.Password + pepper | |
// Create a hash using bcrypt | |
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(pwdWithPepper), bcrypt.DefaultCost) | |
if err != nil { | |
panic(err) | |
} | |
// Store in user struct | |
user := models.User{ | |
Email: form.Email, | |
PasswordHash: string(hashedBytes), | |
} | |
db := dbp.New() | |
defer db.Close() | |
// Store in database | |
err = db.Create(&user).Error | |
if err != nil { | |
panic(err) | |
} | |
// Redirect to profile page | |
http.Redirect(w, req, "/profile", http.StatusFound) | |
} |
// Create adds a new user to db | |
func Create(w http.ResponseWriter, req *http.Request) { | |
form := new(Form) | |
// get password, email into form struct | |
utils.ParseForm(form, req) | |
// Add pepper string to password | |
pwdWithPepper := form.Password + pepper | |
// Create a hash using bcrypt | |
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(pwdWithPepper), bcrypt.DefaultCost) | |
if err != nil { | |
panic(err) | |
} | |
// Store in user struct | |
user := models.User{ | |
Email: form.Email, | |
PasswordHash: string(hashedBytes), | |
} | |
db := dbp.New() | |
defer db.Close() | |
// Store in database | |
err = db.Create(&user).Error | |
if err != nil { | |
panic(err) | |
} | |
// Redirect to profile page | |
http.Redirect(w, req, "/profile", http.StatusFound) | |
} |
上面的代码片段使用了注册步骤。您可以在项目存储库(路径为 )中找到完整的代码
/dev-blog/services/signup.go
。
登录流程
用于bcrypt.CompareHashAndPassword(password, cost)
比较散列密码和其纯文本密码。
上面的代码片段使用了注册步骤。您可以在项目存储库(路径为 )中找到完整的代码
/dev-blog/services/login.go
。
Web 服务器是无状态的
服务器独立处理每个请求。它不保存客户端请求的任何数据来执行操作并做出响应。每个请求都从服务器获取所需的一切并获得响应。
如何让服务器记住您之前在网站上做了什么?
坦白说,我们做不到。我们让客户端在每个请求中告诉他们是谁以及他们需要什么资源。每次浏览时登录都是一项繁琐的任务,因此登录一次后,我们会记录一个cookie(存储在计算机中的数据),这样每次浏览网站时,浏览器都会将 cookie 随每个请求发送到链接的网站。我们将使用此 cookie 数据来验证用户。存储在 cookie 中的此身份验证数据称为Remember Token
。我们之前也在用户模式中添加了它。
记住,token 是一系列一定长度的随机字节。
我们使用以下代码片段创建它:
// GenerateRememberToken returns a 32 bytes random token string using
// crypto/rand packages
func GenerateRememberToken() string {
b := make([]byte, 32) // create a placeholder of 32 bytes (big enough)
_, err := rand.Read(b) // Fill it with random bytes
Must(err)
return base64.URLEncoding.EncodeToString(b) // encoded string
}
将此令牌添加到用户对象(RememberToken)中的字段并保存到数据库。
以下代码片段有助于为网站设置 cookie。
cookie := http.Cookie{
Name: "remember_token",
Value: user.RememberToken,
HttpOnly: true,
Expires: time.Now().Add(24 * time.Hour),
}
http.SetCookie(w, &cookie)
在浏览器中查看并篡改 Cookie 非常容易。为了防止 Cookie 被临时保存,我们可以使用一些选项,例如 HttpOnly(禁止 JavaScript 篡改 Cookie),或者完全不以纯文本形式存储记忆令牌。
我们宁愿保存相同令牌的哈希值,并在每次请求时将其与 Cookie 提供的令牌进行比较。
如果我们使用 bcrypt 哈希,我们会:
- 使用电子邮件从数据库中查找用户
- 使用作为 PasswordHash 字段一部分的盐对用户密码进行哈希处理
- 比较一下。但是,如果使用记住令牌,我们无法从数据库中查找用户,因为我们没有将记住令牌存储在数据库中(只存储了它的哈希值)。我们需要先从 cookie 中获取哈希值,然后再找到用户。像crypto/hmac这样的简单哈希函数就可以了。
// Hash generates hash for given input with secret key of hmac object
func Hash(token string) string {
// sha256 is hashing algorithm
// key can be taken from env variable too
h := hmac.New(sha256.New, []byte("somekey"))
h.Reset() // Clear previous leftover bytes
h.Write([]byte(token))
b := h.Sum(nil)
return base64.URLEncoding.EncodeToString(b)
}
以下是我们将如何使用所有这些:
登录或注册 后,这些 signIn 方法将被调用一次,并且记住令牌将作为 cookie 存储在浏览器中,其哈希版本将保存到数据库中。
现在,当用户访问个人资料或帐户等经过身份验证的页面时,我们可以使用请求中的令牌并将其与存储在数据库中的哈希版本进行比较。
因为我们知道,对于相同的输入字符串,哈希运算会生成相同的输出。我们可以对 cookie 中的记忆令牌进行哈希运算并进行比较。
这些是构建身份验证系统的几个部分。完整的工作项目可以在这里找到。其中还包含用 Go 语言解析 HTML 模板的功能,我在这里写了一个深入的指南:
项目结构经过简化,更易于理解,包括错误处理、每个路由的单独处理文件等。感谢您坚持到最后。

如有任何疑问,欢迎评论或在推特上留言。
我的其他作品:
文章来源:https://dev.to/dpkahuja/learn-and-build-web-authentication-system-universal-principles-370e