最近做一個網站,網站需要用戶登錄註冊,自然也就需要一套高擴展性的用戶模塊設計,該篇文章記錄筆者遇到問題的解決方案,希望對你有幫助。
用戶表設計
登錄包含郵箱密碼登錄以及第三方登錄,且第三方登錄存在不確定性,可能隨時增加或者減少某個渠道。 因此在設計上考慮把用戶基本信息與登錄信息分開,如下所示
清單1:用戶表結構
`user` ( `id` `username` `email` `avatar` `status`
用戶表保存了用戶的基本信息,供站內的一些其他服務查詢使用。
清單2:用戶登錄表
`user_auth` ( `id` `uid` '用戶id', `identity_type` '授權類型', `identifier` '授權標識id', `credential` '授權祕鑰或token', `credential_expire` `status`
用戶登錄表主要保存着用戶的授權信息,這張表是一張基本表,在該授權處可以根據具體登錄業務增加一些額外的字段來滿足需求。存儲時舉個例子:
id | uid | identify_type | identitfier | credential | credential_expire | status |
---|---|---|---|---|---|---|
1 | 張三的id | 站內密碼登錄 | 張三的id | hash(張三的密碼) | 密碼過期時間 | 狀態 |
2 | 張三的id | 微信登錄 | 微信 openId | 微信accessToken | token過期時間 | 狀態 |
3 | 張三的id | Github登錄 | Github openId | Github accessToken | token過期時間 | 狀態 |
這種設計的好處是用戶登錄相關的信息與用戶本身的信息是分離的,可以很輕鬆的擴展或者關閉某一登錄方式,另外由於每一種第三方登錄都是一條記錄,所以還可以得知用戶某一渠道的最後使用登錄時間,供後續分析用戶行爲。
註冊流程
此時註冊流程就相對簡單了,註冊只針對郵箱手機號等站內方式,站外第三方註冊則放到登錄流程裏面做。那麼只需要接收用戶輸入的信息,創建一條user
表數據,再創建一條user_auth
表站內密碼登錄的記錄,這裏就不多分析了。
登錄流程
登錄流程是相對比較複雜的,這裏使用流程圖來描述這一過程:
大體流程分兩種,一種是站內密碼登錄,這種方式比較簡單,就是傳統的密碼判斷是否正確,然後寫回登錄信息。另一種是第三方登錄,該種登錄需要考慮用戶是否只是綁定第三方賬號,是否已經註冊等問題,爲了讓第三方登錄與註冊流暢進行,當用戶未註冊時還需要主動幫其註冊賬號,主動註冊就會涉及到一些用戶表中的必要信息生成,比如郵箱可以生成`[email protected]`等系統默認郵箱。
一些其他問題
1. 站內登錄有必要再細分嗎?比如郵箱登錄和手機號登錄
個人認爲沒必要細分,站內登錄無論是郵箱還是手機號都是用戶的基本信息,因此是可以放入到user
表中,而user_auth
表只保存一條對應用戶密碼設置的記錄就好。
如果細分,則對應user_auth
表中有郵箱登錄與手機號登錄兩個記錄,那麼當修改密碼時就要同時修改,無疑是增加了複雜度。
密碼如何處理才安全?
登錄中用戶密碼如何存儲是一個大問題,密碼一般不存儲明文而是存儲對應的hash值,hash本身是單向流程,那麼破解只能暴力枚舉法或者查表法(事先計算好一批hash值,然後通過數據庫等搜索查找),而後端所需要做的防護是提高這兩種破解方式的成本,好在業內已經有了比較靠譜的解決方案:慢哈希 + 加鹽
處理。
慢哈希
是應對暴力枚舉法的一種方式,暴力枚舉法理論上來說最終一定會找到符合條件的密碼,高端的硬件每秒可進行數十億次hash計算,因此慢哈希
的思路是使hash計算變得緩慢,一般使用多次迭代計算hash方式,那麼即使使用高端硬件,破解速度也是令人無法接受。
加鹽
是應對查表法的一種思路,加鹽的本質是讓用戶的密碼更加複雜,鹽本身是一個隨機值,因此即使同樣的密碼在加鹽後也會得到不同的Hash值,那麼就可以保證查表得到明文後,由於不瞭解加鹽算法,所以也無法得到用戶的實際密碼。
在Java中處理形式如下(此代碼參考自加鹽密碼哈希:如何正確使用):
清單3:Java中密碼加鹽處理
public static String createHash(char[] password) throws NoSuchAlgorithmException, InvalidKeySpecException { // Generate a random salt SecureRandom random = new SecureRandom(); byte[] salt = new byte[SALT_BYTE_SIZE]; random.nextBytes(salt); // Hash the password byte[] hash = pbkdf2(password, salt, PBKDF2_ITERATIONS, HASH_BYTE_SIZE); // format iterations:salt:hash return PBKDF2_ITERATIONS + ":" + toHex(salt) + ":" + toHex(hash); }
大概流程是使用SecureRandom
產生僞隨機數作爲鹽,然後使用pbkdf2
算法迭代一定次數得到密碼所對應的最終hash值,存儲到數據庫的時候形式爲慢哈希迭代次數:鹽:密碼最終hash值
。
然後驗證方式如清單4所示:
清單4:Java中密碼加鹽驗證
public static boolean validatePassword(char[] password, String correctHash) throws NoSuchAlgorithmException, InvalidKeySpecException { // Decode the hash into its parameters String[] params = correctHash.split(":"); int iterations = Integer.parseInt(params[ITERATION_INDEX]); byte[] salt = fromHex(params[SALT_INDEX]); byte[] hash = fromHex(params[PBKDF2_INDEX]); // Compute the hash of the provided password, using the same salt, // iteration count, and hash length byte[] testHash = pbkdf2(password, salt, iterations, hash.length); // Compare the hashes in constant time. The password is correct if // both hashes match. return slowEquals(hash, testHash); }
其中password
是用戶輸入的密碼,correctHash
是加鹽處理得到的結果字符串慢哈希迭代次數:鹽:密碼最終hash值
。那麼必要參數都拿到了,就可以對用戶輸入的密碼進行正向操作,然後把得到的最終hash結果與數據庫中的對比,就能判斷是否輸入正確。
慢哈希性能問題
慢哈希
雖然提高了破解成本,但同樣的也帶來了性能問題,服務端計算一次hash值往往需要幾百毫秒,那麼在大型系統上這裏是很可能成爲性能瓶頸。解決方案一般有兩種:
- 適當的降低慢hash迭代次數。迭代次數低了那麼速度自然就快了,這個要取決於自身的業務是否對安全性有極高的敏感。
- 兩次慢hash,客戶端拿到密碼後,使用用戶的郵箱等固定信息作爲鹽,進行慢哈希迭代。服務端拿到客戶端迭代結果後再次生成鹽進行慢哈希迭代,服務端迭代次數可以小很多。那麼在不改變慢hash目的的情況下把壓力分佈到客戶端來降低服務端開銷。
錯誤信息提示
謹記一個原則:永遠不要告訴用戶是用戶名不對還是密碼不對,要統一的給出用戶名或者密碼不正確
。提高暴力枚舉的成本。
郵箱驗證功能
郵箱驗證功能邏輯是比較簡單的,總體來說後端產生一個100%可靠的鏈接發到用戶郵箱,用戶從該鏈接點擊後可以進行驗證。那麼問題就簡化成如何產生一個100%可靠的鏈接。 這裏比較通用的做法是利用token,token具有時效性,並且與用戶id,所對應的業務相關聯,比較常用的做法是使用JWT Token,JWT本身把時效性,用戶id等都存儲在Token當中,並且Token具有簽名防止僞造或者篡改,關於JWT的更多詳情可以參考我之前寫的相關文章。
有了Token之後,當用戶點擊鏈接,請求到後端,後端再根據Token中的信息進行下一步的判斷。
總結
用戶模塊是網站的基礎,與業務的關係同樣也非常耦合,因此別人的方案大多數只是用來參考,瞭解一些關鍵點的處理做法,比如密碼加鹽,郵箱驗證,具體的設計還需要結合自身業務,切記生搬硬套。 以上大概是我這次做的一個站點中所注意到的事情,希望對你有幫助。