设计一个可扩展的用户登录系统
在Web系統中,用戶登錄是最基本的功能。如何設計一個可擴展的用戶登錄系統呢?本文結合實際案例對用戶登錄系統設計進行多維度的講解,幫助各設計者在應用中將復雜變得簡單。
來源:廖雪峰的官方網站,作者:廖雪峰。
?
【一】
在Web系統中,用戶登錄是最基本的功能。要實現用戶名+密碼登錄,很多同學的第一想法就是直接創建一個Users表,包含username和password兩列,這樣,就可以實現登錄了:
?id | username | password | name等其他字段
----+----------+----------+----------------
?A1 |bob????? | a1b23f2c | ...
?A2 | adam????| c0932f32 | ...
現在問題來了,如果要讓用戶通過第三方登錄,比如微博登錄或QQ登錄,怎么集成進來呢?
以微博登錄為例,由于微博使用OAuth2協議登錄,所以,一個登錄用戶會包含他的微博身份的ID,一個Access Token用于代表該用戶訪問微博的API和一個過期時間。
要集成微博登錄,很多童鞋立刻想到把Users表擴展幾列,記錄下微博的信息:
id | username | password | weibo_id |weibo_access_token | weibo_expires | name等其他字段
----+----------+----------+----------+--------------------+---------------+----------------
?A1 |bob????? | a1b23f2c | W-012345 |xxxxxxxxxx???????? |604800??????? | ...
?A2 | adam????| c0932f32 | W-234567 |xxxxxxxxxx???????? |604800??????? | ...
加一個QQ登錄Users表就又需要加3列,如果這么擴展下去,改表都得累死,不要說維護代碼了。
那怎么才能設計出靈活的登錄呢?
不妨換個角度考慮用戶登錄。當用戶以任意一種方式登錄成功后,我們讀取到的總是Users表對應的一行記錄,它實際上是用戶的個人資料(Profile),而登錄過程只是為了認證用戶(Authenticate),無論是本地用密碼驗證,還是委托第三方登錄,這個過程本質上都是認證。
所以,如果把Profile和Authenticate分開,就十分容易理解了。Users表本身只存儲用戶的Profile:
id | name | birth等其他字段
----+------+-----------------
?A1 | Bob? |? ...
?A2 | Adam | ...
而通過用戶名口令登錄可視為一種Authenticate的方式,利用LocalAuth表維護:
?id | user_id | username | password
----+---------+----------+-----------
?01 |A1????? | bob????? | a1b23f2c
?02 |A2????? | adam???? | c0932f32
通過微博登錄可視為另一種Authenticate方式,利用OAuth表維護:
id | user_id | weibo_id |weibo_access_token | weibo_expires
----+---------+----------+--------------------+---------------
?11 |A1????? | W-012345 |xxxxxxxxxx???????? | 604800
?12 |A2????? | W-234567 |xxxxxxxxxx???????? | 604800
如果要添加另一種OAuth登錄,比如QQ登錄,增加一個表就可以了。不過既然大家都是OAuth家族的,不如統一到一個表,給每家起個名字區分就好了:
id | user_id | oauth_name | oauth_id |oauth_access_token | oauth_expires
----+---------+------------+----------+--------------------+---------------
?11 |A1????? | weibo????? |W-012345 | xxxxxxxxxx???????? | 604800
?12 |A2????? | weibo????? |W-234567 | xxxxxxxxxx???????? | 604800
?13 |A1????? |qq???????? | Q-090807 |xxx-xxx-xxx??????? | 86400
?14 |A2????? |qq???????? | Q-807060 |xxx-xxx-xxx??????? | 86400
如果要增加一種新的登錄方式,比如SAML,那就再加一種類型的表。
有些網站需要API訪問,API可以使用api_key和api_secret來認證,可是怎么把一個API訪問關聯到一個用戶?方法還是增加一種API Auth的表:
?id | user_id | api_key? |api_secret
----+---------+----------+------------
?11 |A1????? | a-012345 | xxxxxxxxxx
?12 | A2?????| a-234567 | xxxxxxxxxx
每一種X-Auth表都存儲了用戶的登錄認證信息,并通過user_id關聯到Users表。這樣一來,不但登錄過程簡化了,而且一個用戶可以使用多種方式登錄。只要登錄成功,拿到了user_id,最后讀取Users表是為了獲得用戶的Profile,這樣讀出來的數據也更安全,因為Users表不包含用戶口令,不會因為暴露API而不小心把口令給泄露出去。
【二】
在上文中,我們設計了可擴展的數據庫表的結構,基本思想是:
·?????????Users表只存儲User的Profile信息,沒有任何認證信息(例如,不存Password);
·????????每一種登錄方式對應一個XxxAuth表,該表存儲對應的認證信息,以及一個userId字段用于關聯到某個User。
數據庫結構再好,代碼寫得亂七八糟,一樣沒法擴展。所以本文討論的,就是如何編寫認證代碼。
現在的問題是,在Web系統中,由于HTTP請求本質上是無狀態的,每個已認證用戶的信息都必須通過Cookie來傳遞。
不對啊,我們無論用ASP、PHP還是JSP,打開服務器的session就可以識別用戶了啊!
少年,服務器的session也無非是靠一個特殊名稱的Cookie來識別而已,只不過由服務器本身幫你完成了解析Cookie、在session中查找User的過程,而代價卻是內存占用高,單臺服務器變成有狀態,無法簡單擴展成集群。遇到不懂事的年輕人,什么都敢往session里扔,很快就把服務器搞死了。
所以,除了演示程序外,我們從不用服務器提供的session。
如果仔細思考用戶的登錄過程,又可以發現,其實不同的登錄方式實現起來復雜度也是不同的。
1.?用戶名+口令登錄
當用戶需要以用戶名+口令來登錄時,我們會讓用戶填寫一個登錄表單,如果驗證通過,就給用戶生成一個可靠的Cookie來標識這個用戶:
如果用戶繼續訪問其他頁面,我們就需要利用這個Cookie來識別用戶。
2.?通過第三方網站登錄
當用戶需要以第三方OAuth登錄時,我們會讓用戶重定向到第三方登錄頁,例如微博登錄頁,如果用戶在第三方登錄成功,第三方會再把用戶重定向回我們的網站,并附上一個code表示是否驗證通過。如果驗證通過,我們還需要給用戶生成一個可靠的Cookie來標識這個用戶:
3.?通過HTTP Authorization Header登錄
這種方式通常不是用戶自己發起的請求,而是由代表用戶的機器發起的請求。因為每個頁面都會附上Authorization: Basic XXXXX這個Header,所以每個頁面都需要驗證。
4.?通過X-API-Token登錄
這種方式和上一種情況類似,也是由代表用戶的機器發起的請求,不同的是用X-API-Token代替了Authorization?Header,更安全可靠。同上,每個頁面都需要驗證。
5.?如何認證
現在問題來了,這么多類型的認證,怎么才能把代碼寫得能看明白?
復雜的問題都要分解成幾步。我們先看通過用戶名+口令的表單登錄。
在這種條件下,用戶首先要被導向到一個登錄URL,例如,/signin,然后填寫用戶名和口令。具體驗證方式就是利用Users表和LocalAuth表,如果驗證成功,我們就創建一個可信的Cookie給用戶。
通過第三方網站登錄也是類似的,要先把用戶導向到登錄URL,登錄成功后,創建一個可信的Cookie。
剩下的問題就只有一個:用戶每訪問一個普通頁面,如何確認用戶身份?
確認用戶身份,我們需要一個統一的Authenticator接口。以Java為例,該接口看起來如下:
public?interface?Authenticator {
????//?認證成功返回User,認證失敗拋出異常,無認證信息返回null:
??? Userauthenticate(HttpServletRequest request,
HttpServletResponse response)?throws?AuthenticateException;
}
接下來,對于每一種類型的認證,我們都編寫一個對應的Authenticator的實現類。例如,針對表單登錄后的Cookie,需要一個LocalCookieAuthenticator:
public?LocalCookieAuthenticator?implements?Authenticator{
????public?User authenticate(HttpServletRequestrequest, HttpServletResponse response) {
????????String cookie =getCookieFromRequest(request,?'cookieName');
????????if?(cookie ==?null) {
????????????return?null;
???????}
????????return?getUserByCookie(cookie);
??? }
}
對于直接用Basic認證的Authorization?Header,我們需要一個BasicAuthenticator:
public?BasicAuthenticator?implements?Authenticator{
????public?User authenticate(HttpServletRequestrequest, HttpServletResponse response) {
????????String auth =getHeaderFromRequest(request,?"Authorization");
????????if?(auth ==?null) {
????????????return?null;
???????}
???????String username = parseUsernameFromAuthorizationHeader(auth);
???????String password = parsePasswordFromAuthorizationHeader(auth);
??? ????return?authenticateUserByPassword(username,password);
??? }
}
對于用API Token認證的方式,同樣編寫一個APIAuthenticator:
public?APIAuthenticator?implements?Authenticator{
????public?User authenticate(HttpServletRequestrequest, HttpServletResponse response) {
???????String token =getHeaderFromRequest(request,?"X-API-Token");
????????if?(token ==?null) {
????????????return?null;
???????}
????????return?authenticateUserByAPIToken(token);
??? }
}
然后在一個統一的入口處,例如Filter里面,把這些Authenticator全部串起來,讓它們依次自己去嘗試認證:
public?class?GlobalFilter?implements?Filter {
????//?所有的Authenticator都在這里:
??? Authenticator[]authenticators = initAuthenticators();
?
????//?每個頁面都會執行的代碼:
????public?void?doFilter(ServletRequest request,ServletResponse response, FilterChain chain) {
????????User user =?null;
????????for?(Authenticator auth :?this.authenticators){
???????????user = auth.authenticate(request, response);
????????????if?(user !=?null) {
????????????????break;
???????????}
???????}
????????// user放哪?
???????chain.doFilter(request, response);
??? }
}
現在,一個可擴展的認證體系在Web層就基本搭建完成了,我們可以隨意組合各種Authenticator,優先級高的放前面。一旦某個Authenticator成功地認證了用戶,后面的Authenticator就不執行了。
最后只剩一個問題:認證成功后的User對象放哪?
放session里?NO,我們在前面已經拒絕了使用服務器提供的session。
放request里?也不好,因為HTTP級別的對象太低級,很難傳到業務層里。
那你說應該放哪?
當然是放到一個與業務邏輯相關的地方了,比如UserContext中。把Filter代碼改寫如下:
public?class?GlobalFilter?implements?Filter {
??? Authenticator[]authenticators = initAuthenticators();
?
????public?void?doFilter(ServletRequest request,ServletResponse response, FilterChain chain) {
????????//?鏈式認證獲得User:
???????User user = tryGetAuthenticatedUser(request, response);
????????//?把User綁定到UserContext中:
????????try?(UserContext ctx =?new?UserContext(user)) {
???????????chain.doFilter(request, response);
???????}
??? }
}
這樣一來,任何地方需要獲得當前User時,只需要寫:
User user =UserContext.getCurrentUser();
是不是太簡單了?
最后總結一下我們編寫認證邏輯的思路:
·????????每一種認證方式都是一種Authenticator的實現;
·????????把所有認證方式串起來,在一個統一的Filter入口來認證;
·????????認證后的User對象用UserContext存儲,并提供一個簡單的方法返回當前User。
好處如下:
·????????認證方式可簡單擴展;
·????????認證邏輯統一在一處。
還有一個最大的好處,就是業務相關的代碼根本就不需要依賴底層HTTP對象,比如session和request,它們只依賴UserContext,這才是真正的解耦,并且非常容易測試業務邏輯,因為不再需要模擬session和request。
趕快按照上述思想,把上面的認證代碼調通后,細心的同學才能發現,至此還遺留了幾個小問題:
·????????表單和OAuth認證成功后,如何生成“可信”的Cookie?
·????????如何根據“可信”的Cookie識別用戶?
·????????UserContext怎么編寫?
這些小問題將會在下面一一解答。
【三】
前面我們討論了用戶認證的數據庫結構和相關代碼。接下來繼續討論幾個遺留問題。
1.?如何生成一個可信的Cookie
因為Cookie都是服務器端創建的,所以,生成一個可信Cookie的關鍵在于,客戶端無法偽造出Cookie。
用什么方法可以防止偽造?數學理論告訴我們,單向函數就可以防偽造。
例如,計算md5就是一個單向函數。假設寫好了函數md5(String s),根據輸入可以很容易地計算結果:
md5("hello")=>?"b1946ac92492d2347c6235b4d2611184"
但是,根據結果"b1946...11184"反推輸入卻非常困難。
利用單向函數,我們可以生成一個防偽造的Cookie。
例如,用戶以用戶名"admin",口令"hello"登錄成功后,要生成Cookie,我們就可以用md5計算:
md5("hello")=>?"b1946ac92492d2347c6235b4d2611184"
然后,把md5值和用戶名"admin"串起來構成一個Cookie發送給客戶端:
"admin:b1946ac92492d2347c6235b4d2611184"
當客戶端把上面的Cookie發給服務器時,服務器如何驗證該Cookie是有效的呢?可以按照以下步驟:
·????????服務器把Cookie分解成用戶名"admin"和md5值"b1946...11184";
·????????根據用戶名"admin"從數據庫中找到該用戶的記錄,并繼續找到該用戶的口令"hello";
·????????服務器根據數據庫中存儲的口令計算md5("hello")并與客戶端Cookie的md5值對比。
如果對比一致,說明Cookie是有效的。
現在可以愉快地為用戶創建Cookie了!
且慢!
從理論到實踐還差著一個工程的距離。上面的算法僅僅解決了基本的驗證,在實際應用中,存在如下嚴重問題:
·????????簡單的md5值很容易被彩虹表攻擊,從而直接得到用戶原始口令;
·????????用戶名被暴露在Cookie中,如果用email作為用戶名,用戶的email就被泄露了;
·????????Cookie沒有設置有效期(注意瀏覽器發過來的Cookie不一定真是瀏覽器發的),導致一旦登錄,永久有效;
·????????其他若干問題。
如何解決?方法是計算hash的時候,不僅只包含用戶口令,還包含Cookie過期時間,以及其他相關隨機數,這樣計算的hash就非常安全。
舉個栗子:
假設用戶仍以用戶名"admin",口令"hello"登錄成功,系統可以知道:
·????????該用戶的id,例如,1230001;
·????????該用戶的口令,例如,"hello";
·????????Cookie過期時間,可由當前時間戳+固定時長計算,例如,1461288165;
·????????系統固定的一個隨機字符串,例如,"secret"。
把上面4部分拼起來,得到:
"1230001:hello:1461288165:secret"
計算上述字符串的md5,得到:"d9753...004d5"。
最后,按照用戶id,過期時間和最終的hash值,拼接得到Cookie如下:
"1230001:1461288165:d9753...004d5"
當瀏覽器發送Cookie回服務器時,我們就可以按照下面的方式驗證Cookie:
·????????把Cookie分割成三部分,得到用戶id,過期時間和hash值;
·????????如果過期時間已到,直接丟棄;
·????????根據用戶id查找用戶,得到用戶口令;
·????????按照生成Cookie時的算法計算md5,與Cookie自帶的hash值對比。
如果用戶自己對Cookie進行修改,無論改用戶id、過期時間,還是hash值,都會導致最終計算結果不一致。
即使用戶知道自己的id和口令,也知道服務器的生成算法,他也無法自己構造出有效的Cookie,原因就在于計算hash時的“系統固定的隨機字符串”他不知道。
這個“系統固定的隨機字符串”還有一個用途,就是編寫代碼的開發人員不知道生產環境服務器配置的隨機字符串,他也無法偽造Cookie。
md5算法還可以換成更安全的sha1/sha256。
現在我們就解決了如何生成一個可信Cookie的問題。
如果用戶通過第三方OAuth登錄,服務器如何生成Cookie呢?
方法和上面一樣,具體算法自己想去。
2.?如何綁定用戶
如果用戶被認證了,系統實際上就認為從數據庫讀取的一個User對象是有效的當前用戶,現在的問題是,如何讓業務層代碼獲知當前用戶。
方法一:每個業務方法新增一個User參數。
該方法太弱智,故不在此處討論。
方法二:把User綁定到request中。
該方法太幼稚,導致編寫業務的時候需要這么寫:
User user = (User)request.getAttribute("USER");
問題一大堆:
·????????Key值"USER"需要定義到常量中,但不排除很多開發人員偷懶直接寫死了,這樣編譯器根本檢測不到錯誤;
·????????某個零經驗的開發人員在某處放置了request.setAttribute("USER", true)的代碼,導致后續操作直接崩潰;
·????????request對象怎么拿?再寫一個SpringHelper.getContext().getCurrentRequest()?
·????????強制轉型看著就不爽。
正確做法:把User用ThreadLocal綁定到當前處理線程:
public?class?UserContext {
????public?static?final?ThreadLocal<User> current =?new?ThreadLocal<User>();
}
在統一的入口,例如Filter處理:
public?class?MyFilter?implements?Filter{
????public?void?doFilter(ServletRequest request,ServletResponse response, FilterChain chain) {
???????User user = tryGetAuthenticatedUser(request, response);
???????UserContext.current.set(user);
???????chain.doFilter(request, response);
???????UserContext.current.remove(user);
??? }
}
這樣就可以在業務邏輯的任何地方獲得當前User:
User user = UserContext.current.get();
上述代碼是零經驗工程師寫的,大家不要學。
有經驗的工程師會指出,沒有try...finally邏輯就不對,但這只是知道Java語法后的生搬硬套,也不對。
這段代碼的真正問題是缺少封裝,沒有把實現細節隱藏起來。大家熟知的開閉原則“對擴展開放,對修改關閉”,說起來容易,實現起來困難。
讓我們用開閉原則重寫上面的代碼:
public?class?UserContext?implements?AutoCloseable{
?
????static?final?ThreadLocal<User> current =?new?ThreadLocal<User>();
?
????public?UserContext(User user) {
???????current.set(user);
??? }
?
????public?static?User getCurrentUser() {
????????return?current.get();
??? }
?
????public?void?close() {
???????current.remove();
??? }
}
是不是簡單多了?
代碼量大了,難道還更簡單了?
是的,簡單與否不看代碼量本身,而是看調用起來是不是簡單。在Filter中調用起來就非常簡單:
public?class?MyFilter?implements?Filter {
????public?void?doFilter(ServletRequest request,ServletResponse response, FilterChain chain) {
???????User user = tryGetAuthenticatedUser(request, response);
????????try?(UserContext context =?new?UserContext(user)) {
???????????chain.doFilter(request, response);
???????}
??? }
}
finally哪去了?與時俱進是我們的原則之一,搜索一下AutoCloseable吧!
在業務邏輯中調用更簡單:
User user =UserContext.getCurrentUser();
最后我們來演示一下很多場景需要的用法:
try?(UserContext context =?new?UserContext(user)){
????//?當前用戶是user:
???processProfile(UserContext.getCurrentUser());
????//?需要更高權限的admin才能執行的操作怎么辦?
????//?方法是獲取一個admin用戶:
????try?(UserContext context =?new?UserContext(getAdmin())){
????????//?現在的當前用戶是admin:
???????processAdminJob(UserContext.getCurrentUser());
??? }
????//?現在當前用戶又自動變回了普通user:
???processProfile(UserContext.getCurrentUser());
}
實現上述邏輯只需要對UserContext做一個簡單的修改就可以實現了。
這才是真正的開閉啊!
?JDK API 1.6.0?中文版免費下載地址:http://down.51cto.com/data/2300228
本文轉自 wyait 51CTO博客,原文鏈接:http://blog.51cto.com/wyait/1910022,如需轉載請自行聯系原作者
總結
以上是生活随笔為你收集整理的设计一个可扩展的用户登录系统的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 「CodePlus 2017 11 月赛
- 下一篇: windows桌面快捷方式图标上面怎么老