工作--用戶登錄註冊相關設計

最近做一個網站,網站需要用戶登錄註冊,自然也就需要一套高擴展性的用戶模塊設計,該篇文章記錄筆者遇到問題的解決方案,希望對你有幫助。


用戶表設計

登錄包含郵箱密碼登錄以及第三方登錄,且第三方登錄存在不確定性,可能隨時增加或者減少某個渠道。 因此在設計上考慮把用戶基本信息與登錄信息分開,如下所示

清單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值往往需要幾百毫秒,那麼在大型系統上這裏是很可能成爲性能瓶頸。解決方案一般有兩種:

  1. 適當的降低慢hash迭代次數。迭代次數低了那麼速度自然就快了,這個要取決於自身的業務是否對安全性有極高的敏感。
  2. 兩次慢hash,客戶端拿到密碼後,使用用戶的郵箱等固定信息作爲鹽,進行慢哈希迭代。服務端拿到客戶端迭代結果後再次生成鹽進行慢哈希迭代,服務端迭代次數可以小很多。那麼在不改變慢hash目的的情況下把壓力分佈到客戶端來降低服務端開銷。

錯誤信息提示

謹記一個原則:永遠不要告訴用戶是用戶名不對還是密碼不對,要統一的給出用戶名或者密碼不正確。提高暴力枚舉的成本。

郵箱驗證功能

郵箱驗證功能邏輯是比較簡單的,總體來說後端產生一個100%可靠的鏈接發到用戶郵箱,用戶從該鏈接點擊後可以進行驗證。那麼問題就簡化成如何產生一個100%可靠的鏈接。 這裏比較通用的做法是利用token,token具有時效性,並且與用戶id,所對應的業務相關聯,比較常用的做法是使用JWT Token,JWT本身把時效性,用戶id等都存儲在Token當中,並且Token具有簽名防止僞造或者篡改,關於JWT的更多詳情可以參考我之前寫的相關文章

有了Token之後,當用戶點擊鏈接,請求到後端,後端再根據Token中的信息進行下一步的判斷。

總結

用戶模塊是網站的基礎,與業務的關係同樣也非常耦合,因此別人的方案大多數只是用來參考,瞭解一些關鍵點的處理做法,比如密碼加鹽,郵箱驗證,具體的設計還需要結合自身業務,切記生搬硬套。 以上大概是我這次做的一個站點中所注意到的事情,希望對你有幫助。

參考

加鹽密碼哈希:如何正確使用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章