通過實例理解Web應用用戶密碼存儲方案
在上一篇文章《通過實例理解Go Web身份認證的幾種方式》[1]中,我們了解了Web應用的多種身份驗證方式。但無論哪種方式,用戶初次訪問Web應用的注冊流程和登錄流程是不可避免的,而基于用戶名密碼的注冊流程依舊是當今主流。注冊后,Web應用后端是如何保存用戶密碼的呢?歷史上都有哪些存儲方案?當今的主流存儲方案又是什么呢?在這篇文章中,我們就來說說Web應用的各種密碼存儲方案的優缺點,并通過實例來理解一下當前的主流存儲方案。
1. Web應用用戶密碼存儲的重要性
用戶密碼是訪問Web應用的關鍵,它直接關乎到用戶賬號和應用數據的安全。
如果用戶密碼被泄露或破解,將導致嚴重后果。后果最輕的算是某個用戶或某少數用戶的賬號被盜用了,用戶將失去對賬號的控制。盜用賬號后,攻擊者可以獲取該用戶的私密信息,或進行額外的攻擊;如果用戶在多個應用重復使用同一密碼,那么后果將進一步嚴重,用戶的一系列賬號都將受到安全威脅;更為嚴重的是Web應用存儲用戶賬號信息的數據庫被攻破(俗稱“脫庫”),攻擊者會拿到存儲的全部用戶賬號信息等,如果用戶密碼存儲不當,攻擊者可以很容易破譯所有用戶的密碼,并基于這些密碼信息做進一步的攻擊。
由此可見,Web應用必須非常重視用戶密碼的存儲安全。在當前弱密碼和頻繁密碼泄露成為常態的背景下,Web應用開發者有責任使用安全的密碼存儲方案,盡力保護用戶信息安全,即便在被脫庫的最糟糕情況下,也不讓攻擊者輕易破解出用戶的密碼,這也關系到應用和企業的信譽。
2. 密碼存儲方案的演進:魔高一尺,道高一丈
Web應用用戶密碼存儲方案的演進歷史可以分為以下幾個階段,如圖所示:
圖片
下面我們按圖中的演進順序,對各階段的密碼存儲方案逐一說明一下。
2.1 起始階段 - 明文存儲
早期的Web應用為了實現簡單,采用了最簡單“粗暴”的用戶密碼存儲方式:明文存儲,即直接把用戶的密碼以純文本形式存儲在數據庫中。
顯然這種方式的最大優點就是實現簡單,驗證登錄時直接比對明文密碼。但這種方式最大的缺點就是極其不安全,密碼一旦泄露就失去了全部保密性。但當時人們的安全意識較弱,該方案被廣泛使用。
2.2 弱哈希算法階段 - MD5和SHA1
隨著時間的推移,CPU和GPU性能的提升使得字典破解和窮舉攻擊更加可行有效,大量密碼被泄露的事件引起人們對密碼安全的重視,人們更多地認識到明文存儲密碼的危險性。同時,Web應用的發展也從追求功能和便利,轉變為在易用性與安全性之間求平衡。政府和行業協會也開始指定密碼存儲的最新安全要求的規范和政策,密碼學等相關技術的快速發展也為更安全的密碼存儲提供了前提和支持。
于是人們開始使用MD5、SHA1等單向哈希算法對密碼進行處理,只存儲密碼的哈希值。雖然增加了一定的密碼存儲的復雜性,但其最大的優點就是在一定程度上放置了明文存儲的密碼泄露問題。
不過,隨著大量使用MD5和SHA-1的應用遭到破解,這些哈希算法的脆弱性暴露無遺。同時彩虹表攻擊的出現,讓破解者只需要預計算密碼哈希表就可以快速破解以弱哈希存儲的密碼。
于是技術社區以及安全規范都開始提倡和推薦采用更安全的密碼存儲方案,即采用加鹽方案。
2.3 加鹽哈希階段 - 增加隨機鹽值
加鹽哈希就是在計算密碼的哈希值時,在密碼字符串前/后面添加一個稱為“鹽(salt)”的隨機字符串,這個隨機字符串稱為鹽值,它的作用是增加哈希后密碼的隨機性。
加鹽哈希的步驟大致如下圖:
圖片
在用戶注冊階段,系統根據用戶輸入的密碼生成在數據庫中的哈希密碼值:
- 系統首先隨機生成一個足夠長的隨機字符串作為鹽值,可以使用密碼學安全的隨機數生成算法;
- 將鹽值與用戶輸入的原始密碼字符串拼接在一起(鹽值放在密碼的前后均可);
- 對連接后的字符串計算哈希值,可以使用MD5、SHA-1、SHA256、SHA-512等哈希算法;由于也被證實MD5、SHA-1存在弱點,可以被碰撞攻擊,建議至少使用SHA256算法;
- 將鹽值和哈希值一起存儲在數據庫中(可以向圖中那樣將hashed_password和salt通過:分隔符組合為一個字段后再存儲在數據庫中)。
驗證登錄時,系統根據用戶名取出鹽值,然后將用戶輸入的密碼與鹽值組合計算哈希值,與存儲的原始哈希值比較,相同則驗證成功。
在密碼哈希前加入隨機字符串(即“鹽(salt)”)可以大幅增加了破解難度,同時不同用戶如采用相同密碼,也可以通過不同的鹽在哈希后得到不同的哈希值,這可以有效地防止預計算表的攻擊。
不過隨著硬件算力的飛速提高,比如GPU、專用ASIC芯片以及云計算資源等,密碼破解效率進一步提高,甚至普通人也可利用現成的破解工具和云資源進行密碼破解,攻擊者門檻大幅降低,簡單加鹽也已出現不能有效對抗硬件加速破解的情況。
于是人們開始考慮使用一些新哈希算法,這些算法可以大幅提高攻擊者付出的時間和資源消耗成本,增加密碼破解難度,這就是下面我們要說的慢哈希算法。
2.4 慢哈希算法階段 - Argon2、Bcrypt、Scrypt和PBKDF2
Argon2[2]、Bcrypt[3]、Scrypt[4]和PBKDF2[5]是目前主流的慢哈希算法,它們與SHA256等快速哈希算法的主要差異點如下:
- 計算速度更慢,需要消耗更多CPU和內存資源,從而對抗硬件加速攻擊;
- 使用更復雜的算法,組合密碼學原語,增加破解難度;
- 可以配置資源消耗參數,調整安全強度;
- 特定優化使并行計算困難;
- 經過長時間的密碼學分析,仍然安全可靠。
從這些特點可以知道:這些慢哈希算法更適合密碼哈希的原因是可以大幅增加攻擊者密碼破解的成本,如果這么說大家印象還不夠深刻,我們就來量化對比一下,下面是以SHA256和Scrypt兩個算法為例做的一個簡單的benchmark測試:
// web-app-password-storage/benchmark/benchmark_test.go
package main
import (
"crypto/sha256"
"testing"
"golang.org/x/crypto/scrypt"
)
func BenchmarkSHA256(b *testing.B) {
b.ReportAllocs()
data := []byte("hello world")
b.ResetTimer()
for i := 0; i < b.N; i++ {
sha256.Sum256(data)
}
}
func BenchmarkScrypt(b *testing.B) {
b.ReportAllocs()
const keyLen = 32
data := []byte("hello world")
b.ResetTimer()
for i := 0; i < b.N; i++ {
scrypt.Key(data, data, 16384, 8, 1, keyLen)
}
}
我們看看輸出的benchmark結果是什么樣的:
$go test -bench .
goos: darwin
goarch: amd64
pkg: demo
... ...
BenchmarkSHA256-8 6097324 195.3 ns/op 0 B/op 0 allocs/op
BenchmarkScrypt-8 26 41812138 ns/op 16781836 B/op 22 allocs/op
PASS
ok demo 2.533s
我們看到無論是cpu消耗還是內存開銷,Scrypt算法都是SHA256的幾個數量級的倍數。
加鹽的慢哈希也是目前的主流的用戶密碼存儲方案,那有讀者會問:這四個算法選擇哪個更佳呢?說實話要想對這個四個算法做個全面的對比,需要很強的密碼學專業知識,這里直接給結論(當然也是來自網絡資料):建議使用Scrypt或Argon2系列的算法,它們倆可提供更高的抗ASIC和并行計算能力,Bcrypt由于簡單高效和成熟,目前也仍十分流行。
不過,慢哈希算法在給攻擊者帶來時間和資源成本等困難的同時,也給服務端正常的身份認證帶來一定的性能開銷,不過大多數開發者認為這種設計取舍是值得的。
下面我們就基于慢哈希算法結合加鹽,用實例說明一下一個Web應用的用戶注冊與登錄過程中,密碼是如何被存儲和用來驗證用戶身份的。
3. 加鹽哈希存儲方案的示例
在這個示例中,我們建立兩個html文件:一個是signup.html,用于模擬用戶注冊;一個是login.html,用于模擬用戶登錄:
// web-app-password-storage/signup.html
<!DOCTYPE html>
<html>
<head>
<title>注冊</title>
</head>
<body>
<form actinotallow="http://localhost:8080/signup" method="post">
<label>用戶名:</label>
<input type="text" name="username"/>
<label>密碼:</label>
<input type="password" name="password"/>
<label>確認密碼:</label>
<input type="password" name="confirm-password"/>
<button type="submit">注冊</button>
</form>
</body>
</html>
// web-app-password-storage/login.html
<!DOCTYPE html>
<html>
<head>
<title>登錄</title>
</head>
<body>
<form actinotallow="http://localhost:8080/login" method="post">
<label>用戶名:</label>
<input type="text" name="username"/>
<label>密碼:</label>
<input type="password" name="password"/>
<button type="submit">登錄</button>
</form>
</body>
</html>
接下來,我們來寫這個web應用的后端:一個http server:
// web-app-password-storage/server/main.go
package main
import (
"database/sql"
"encoding/base64"
"math/rand"
"net/http"
"strings"
"time"
"golang.org/x/crypto/scrypt"
_ "modernc.org/sqlite"
)
var db *sql.DB
func main() {
// 連接SQLite數據庫
var err error
db, err = sql.Open("sqlite", "./users.db")
if err != nil {
panic(err)
}
defer db.Close()
// 創建用戶表
sqltable := `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
hashedpass TEXT
);
`
_, err = db.Exec(sqltable)
if err != nil {
panic(err)
}
http.HandleFunc("/login", login)
http.HandleFunc("/signup", signup)
http.ListenAndServe(":8080", nil)
}
func signup(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
cpassword := r.FormValue("confirm-password")
if password != cpassword {
http.Error(w, "password and confirmation password do not match", http.StatusBadRequest)
return
}
// 注冊新用戶
salt := generateSalt(16)
hashedPassword := hashPassword(password, salt)
stmt, err := db.Prepare("INSERT INTO users(username, hashedpass) values(?, ?)")
if err != nil {
panic(err)
}
_, err = stmt.Exec(username, hashedPassword+":"+salt)
if err != nil {
panic(err)
}
w.Write([]byte("signup ok!"))
}
func login(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
// 驗證登錄
storedHashedPassword, salt := getHashedPasswordForUser(db, username)
hashedLoginPassword := hashPassword(password, salt)
if hashedLoginPassword == storedHashedPassword {
w.Write([]byte("Welcome!"))
} else {
http.Error(w, "Invalid username or password", http.StatusUnauthorized) // 401
}
}
// 生成隨機字符串作為鹽值
func generateSalt(n int) string {
rand.Seed(time.Now().UnixNano())
letters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
// 對密碼進行bcrypt哈希并返回哈希值與隨機鹽值
func hashPassword(password, salt string) string {
dk, err := scrypt.Key([]byte(password), []byte(salt), 1<<15, 8, 1, 32)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(dk)
}
// 從數據庫獲取用戶哈希后的密碼和鹽值
func getHashedPasswordForUser(db *sql.DB, username string) (string, string) {
var hashedPass string
row := db.QueryRow("SELECT hashedpass FROM users WHERE username=?", username)
if err := row.Scan(&hashedPass); err != nil {
panic(err)
}
split := strings.Split(hashedPass, ":")
return split[0], split[1]
}
示例的結構比較清晰,這里提供了兩個http handler,一個是signup用于接收用戶注冊請求,一個是login,用于接收處理用戶登錄請求。在注冊請求時,我們生成用戶密碼的帶鹽慢哈希值,與salt一起存入數據庫,這里用sqlite代替通用關系型數據庫;在login handler中,我們根據username讀取數據庫中的salt和hashed_password,然后基于請求中的password與salt重新做一遍hash,將得到的結果與數據庫中讀取的hashed_password比較,相同則說明用戶輸入的密碼正確。
Go官方維護的golang.org/x/crypto為我們提供了高質量的scrypt包,當然crypto下也有bcrypt、argon2和pbkdf2的實現,感興趣的童鞋可以自行研究。
4. 小結
用戶密碼的安全存儲是保障Web應用與用戶數據安全的基石。簡單的密碼存儲實踐如明文和弱哈希算法存在巨大隱患,而隨著計算能力提升,任何weak password都可被輕松破解。為有效保護用戶,Web應用必須采取更可靠的密碼存儲方案。
本文詳細介紹了從簡單明文、單向哈希到先進的加鹽慢哈希的演進歷程。我們看到,這是一場與不斷增強的攻擊手段進行的應對之爭。隨著硬件計算能力、并行與云計算等技術進步,必須加強密碼存儲機制的強度。當前,結合隨機鹽、迭代計算的慢哈??纱蠓岣咂平怆y度,是推薦的密碼存儲安全實踐。
當然,密碼安全需要持續關注新興攻擊手段,并及時采納更強大的算法。這不僅是技術問題,也需要整個社區的共同努力,通過提高意識和最佳實踐來保護用戶。
本文示例所涉及的Go源碼可以在這里[6]下載。
5. 參考資料
- 《API安全實戰》[7] - https://book.douban.com/subject/36039150/
- 《API安全技術與實戰》[8] - https://book.douban.com/subject/35429043/
- 保密:系統如何保證敏感數據無法被內外部人員竊取濫用?[9] - https://time.geekbang.org/column/article/334293