用户信息安全至关重要,而密码作为用户身份验证的关键环节,其安全存储更是重中之重。简单地将密码以明文形式存储在数据库中,一旦数据泄露,用户将面临巨大的安全风险。

本文将介绍如何使用 Golang 语言结合 bcrypt 算法,实现安全的密码认证和存储机制,保护用户敏感数据,为您的 Web 应用构建坚实的安全屏障。 文章将通过具体的代码示例和实际应用场景,带您逐步了解 bcrypt 密码哈希的原理和使用方法,从用户注册到登录,构建完整的密码安全流程。 无论您是 Golang 新手还是经验丰富的开发者,本文都将为您提供有价值的参考,帮助您提升 Web 应用的安全性。

Golang 密码认证与存储概述:保障 Web 应用安全

如何安全存储密码?Golang bcrypt 加密详解

如前所述,我们不应该在收到用户密码时直接存储它们。

我们需要对每一个密码进行加密转换,使其易于验证,但不易被猜到。我们使用单向散列函数(在本例中为 bcrypt 算法)来实现这一点。

bcrypt 是由 Niels Provos 和 David Mazières 设计的密码散列函数,基于 Blowfish 密码,并于 1999 年在 USENIX 上提出。 除了加入盐来防止彩虹表攻击外,bcrypt 还是一种自适应函数:随着时间的推移,可以增加迭代次数以使其变慢,因此即使计算能力增加,它仍然可以抵抗暴力搜索攻击。

bcrypt 函数的输入是密码字符串(最多 72 字节)、数字成本和 16 字节(128 位)盐值。 盐通常是随机值。 bcrypt 函数使用这些输入来计算 24 字节(192 位)的散列。 bcrypt 函数的最终输出是以下形式的字符串:

$2<a/b/x/y>$[cost]$[22 character salt][31 character hash]`

bcrypt 函数是 OpenBSD 的默认密码哈希算法,也是某些 Linux 发行版(例如 SUSE Linux)的默认值。

将一段文本转换为哈希很容易,但几乎不可能根据哈希猜出这段文本。

例如,输入密码 abc123xyz、成本 12 和随机盐,bcrypt 的输出是字符串:

$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
\__/\/ \____________________/\_____________________________/
Alg Cost      Salt                        Hash

在登录期间,我们可以通过检索该用户名的密码哈希值来检查给定用户名的密码是否正确,并且必须使用 bcrypt 比较函数来验证给定密码与哈希值:

加密的密码 ------- [bcrypt compare] -------> true/false
                              ^
                              |
登录密码hash ------------------

如果只是简单的拿当前登录密码再进行一次同样的加密,再去与原始的加密结果进行比较是无法成功的,两次加密结果是不一样的,必须使用 bcrypt 的比较函数进行校验。

现在,让我们看看如何使用 HTTP Web 服务器实现注册和登录。

Golang bcrypt 实战:构建用户注册和登录接口 HTTP 服务器实现

我们将构建一个具有两条路由的 HTTP 服务器:/signup/signin,为了方便演示,我们使用 SQLite 数据库存储用户凭据。

  • /signup 将接受用户凭据,并将它们安全地存储在我们的数据库中。
  • /signin 将接受用户凭据,并通过将它们与数据库中的条目进行比较来验证它们。
user ---username,password--> (/signup) --> [bcrypt hash] ---store hash--> db

user ---username,password--> (/signin) --> [bcrypt compare] <--retrieve hash-- db

相关阅读推荐:Golang Web 开发简明教程:从零开始构建你的第一个 Web 应用

Golang Web 应用搭建:数据库与 HTTP 服务器初始化

在我们实现密码存储之前,让我们创建我们的数据库并初始化我们的 HTTP 服务器:

创建我们的数据库

使用 sqlite3 命令在我们的项目目录中创建一个新数据库 mydb

cd blogpost-demo-bcrypt-auth
sqlite3 ./mydb

然后,创建用户表,其中包含用户名和密码列:

create table users (
   username text primary key,
   password text
);

初始化 HTTP 服务器

package main

import (
	"database/sql"
	"log"
	"net/http"

	_ "github.com/glebarez/go-sqlite"
)

// “db”将保存对我们数据库实例的引用
var db *sql.DB

func main() {
	// "Signin" 和 "Signup" 是我们要实现的处理函数
	http.HandleFunc("/signin", Signin)
	http.HandleFunc("/signup", Signup)
	// 初始化我们的数据库连接
	initDB()
	// 在端口 8000 上启动服务器
	log.Fatal(http.ListenAndServe(":8000", nil))
}

func initDB() {
	var err error
	// 连接到 sqlite 数据库
	db, err = sql.Open("sqlite", "./mydb")
	if err != nil {
		panic(err)
	}
}

Golang Web 应用用户注册接口实现:使用 bcrypt 安全存储密码

为了创建用户或注册用户,我们将编写一个接受 POST 请求的处理函数,其请求 JSON 主体为以下形式:

{
  "username": "axiaoxin",
  "password": "666888"
}

如果用户已成功注册,处理函数将返回 200 状态:

// 创建一个对用户模型
type Credentials struct {
	Password string `json:"password", db:"password"`
	Username string `json:"username", db:"username"`
}

func Signup(w http.ResponseWriter, r *http.Request) {
	// 将请求主体解析并解码为 `Credentials` 实例
	creds := &Credentials{}
	err := json.NewDecoder(r.Body).Decode(creds)
	if err != nil {
		// 如果请求体有问题,返回 400 状态
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	// 使用 bcrypt 算法对密码进行加盐和哈希散列处理
	// 第二个参数是散列的成本,我们设置为 8(这个值可以任意设置,可以多也可以少,取决于你希望利用的计算能力)
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(creds.Password), 8)

	// 接下来,将用户名和哈希处理后的密码插入数据库
	if _, err = db.Exec("insert into users values (?, ?)", creds.Username, string(hashedPassword)); err != nil {
		// 如果插入数据库有任何问题,返回 500 错误
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	// 到此,我们完成了数据库的保存,将默认返回状态 200
}

此时,我们可以启动服务器并尝试发送请求来存储一些凭据:

curl -i -XPOST https://localhost:8000/signup \
 -H 'Content-Type: application/json' \
 -d '{"username":"axiaoxin","password":"666888"}'

如果我们现在检查我们的数据库,我们可以看到密码字段不包含我们刚才发送的密码:

sqlite> select * from users;
axiaoxin|$2a$08$4Ba1Tt7R5DBafKkcFsVJWeaqyf.tPoS8q32fhpq2t5GPBProuOB8S
sqlite>

一旦使用 bcrypt 对密码进行哈希处理,我们就无法逆转哈希。本质上,即使我们可以查看用户数据表,我们自己也不可能知道用户的原始密码。

Golang Web 应用用户登录接口实现:bcrypt 密码验证与授权

如何使用 bcrypt 进行密码验证?

现在编写登录处理函数,根据我们数据库中用户名和密码对用户进行身份验证。

func Signin(w http.ResponseWriter, r *http.Request) {
	// 将请求体解析为 `Credentials` 实例
	creds := &Credentials{}
	err := json.NewDecoder(r.Body).Decode(creds)
	if err != nil {
		// 如果请求体有问题,返回 400 状态
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	// 根据请求用户名获取数据库中对应的用户
	result := db.QueryRow("select password from users where username=?", creds.Username)
	if err != nil {
		// 如果数据库有问题,返回 500 错误
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	// 我们创建另一个 `Credentials` 实例来存储我们从数据库中获取的凭据
	storedCreds := &Credentials{}
	// 将获得的密码存储在 storedCreds 中
	err = result.Scan(&storedCreds.Password)
	if err != nil {
		// 如果登录用户名不存在,则发送“未授权”(401) 状态
		if err == sql.ErrNoRows {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		// 如果错误是任何其他类型,则发送 500 状态
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	// 将数据库中的哈希密码与用户当前登录的密码的哈希版本进行比较
	if err = bcrypt.CompareHashAndPassword([]byte(storedCreds.Password), []byte(creds.Password)); err != nil {
		// 如果两个密码不匹配,返回 401 状态
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	// 到达这里,就意味着用户密码是正确的,并且他们被授权
	// 默认将返回200状态
}

现在,我们可以尝试通过向 /signin 路由发出 POST 请求来登录:

curl -i -XPOST https://localhost:8000/signin \
 -H 'Content-Type: application/json' \
 -d '{"username":"axiaoxin","password":"666888"}'
HTTP/1.1 200 OK

这会给你一个 200 状态码。如果我们使用不正确的密码或不存在的用户名发出请求,我们将获得 401 状态代码:

curl -i -XPOST https://localhost:8000/signin \
 -H 'Content-Type: application/json' \
 -d '{"username":"axiaoxin","password":"666xxx"}'
HTTP/1.1 401 Unauthorized

如果你想运行本示例的服务器,你可以在这里获取源代码在本地运行。

在此示例中,我们简单的演示了用户注册和登录。然而,在现实世界中,大多数用户都希望继续使用您的网站。

在这种情况下,即使他们转到我们网站上的另一个页面,我们也需要知道这个用户已经登录过了。否则,用户将不得不一遍又一遍地登录。

我们可以使用 session 以及 cookie 来实现这一点。 “cookie”是保存在您的用户浏览器中的一段数据,使我们能够在整个网站上识别他们。

相关阅读:Golang Web 开发教程:基于 Session Cookie 实现身份验证


也可以看看