Web總結:使用者認證
前言
使用者認證就是判斷一個使用者是否為合法使用者的過程。
目前使用者認證大都是基於Cookie、Session實現的。對於HTTP協議還不熟悉的話,可以參考《HTTP權威指南》,PDF版下載(密碼:7u67)。
應用場景
註冊、登陸幾乎是所有Web站點都具備的兩個功能。
以商城系統為例,使用者輸入登入名、密碼進行註冊、登陸,這樣系統內就可以為使用者儲存如:購物車、訂單、商品喜好等個性化資訊。
使用者認證的最主要目的是儲存個性化資訊。
使用者認證是使用者授權的基礎。以商城系統為例,商家需要先進行使用者認證,系統才能判斷他是否有某個店鋪的管理權。
API呼叫和網頁瀏覽一樣,也需要使用者認證。
版本1:基於Session
Session是一種將資料儲存在伺服器端的會話控制技術,我們可以使用它實現使用者認證。
下面是一個基於Laravel5的PHP版本的使用者認證:
/**
* 使用者登入
* @param string $login 登入名
* @param string $password 登入密碼
* @return UserModel|false
*/
function userLogin($login, $password) {
$user = UserModel::where('login', $login)->first();
if ($user && $user->checkPassword($password)) {
session()->put('_user', $user);
return $user;
} else {
return false;
}
}
/**
* 獲取已經登入的使用者例項
* @return UserModel|null
*/
function getLoginUser() {
return session()->get('_user');
}
userLogin
函式接受使用者名稱、密碼兩個引數進行使用者認證工作,認證成功返回使用者例項
false
。
getLoginUser
函式用於獲取已經登入的使用者,已登入返回使用者例項
,未登入返回null
(由session()->get函式返回的)。
第8行:按$login
從資料庫中取出匹配的第一個使用者例項
。
第9行:判斷是否認證成功,checkPassword
用於判斷$password
是否符合$user
的密碼。
第10行:將$user
存入session中,鍵為_user
。
第11行:認證成功,返回使用者例項$user
。
第13行:認證失敗,返回false
。
第22行:從session中取出使用者例項。
這種做法的核心思想是把使用者資料直接交由Session保管。
Session可以基於Cookie或URL實現,不論哪種形式,都需要先由伺服器種下session-id
(種在Cookie裡或是重在URL裡),後續請求帶上這個session-id,伺服器才能實現Session。
版本2:基於令牌Token
API請求大多會使用HTTP Client完成,它是不帶瀏覽器的Cookie(除非手動設定)。同時,API請求大都都只有一個請求和一個響應,session-id是來不及種的。
基於令牌的使用者認證,本質是將登入時隨機生成的token
寫在HTTP頭或是寫在URL上,伺服器通過鑑別token
來進行使用者認證。
上程式碼:
/**
* 使用者登入
* @param string $login 登入名
* @param string $password 登入密碼
* @return UserModel|false
*/
function userLogin($login, $password) {
$user = UserModel::where('login', $login)->first();
if ($user && $user->checkPassword($password)) {
$token = $user->generateAuthToken();
session()->put('_token', $token);
cache()->put('user_' . $token, $user);
return $user;
} else {
return false;
}
}
/**
* 獲取已經登入的使用者例項
* @return UserModel|null
*/
function getLoginUser($token = null) {
if (! $token) $token = session()->get('_token');
$cache_key = 'user_' . $token;
return cache()->get($cache_key);
}
這個版本的userLogin
函式,在認證成功後,通過使用者例項生成一個token
放入session,再把使用者例項$user
放入快取系統中(如Redis、Memcache)。token
一般都是32位的md5值。
getLoginUser
函式也有所變化,它可以接受指定的$token
來獲取使用者例項,預設情況下它會從session中取出token。
第10~12行:使用$user
生成token
,將使用者例項存入快取系統中。
第24~26行:使用token
從快取系統中獲取使用者例項。
的一種可用的用於生成token
的方法:
/**
* 生成認證token
* @return string 認證token
*/
public function generateAuthToken() {
if ($this->token) return $this-token;
return $this->token = md5(md5($this->id . time()));
}
time()
函式返回當前unix時間戳。可以看到,token與使用者id
和登入時間
有關,這可以保證唯一性。
這樣的使用者認證下,API請求怎麼做呢?
我們先建立一個介面 /login
用於登入,介面的返回值裡,附上登入成功後的 token
,HTTP Client將這個token快取起來,在之後的請求中帶上這個token即可。這樣以來,使用者認證就不是基於Cookie而是基於token了。
這樣的使用者認證已經可以滿足大部分應用場景瞭如Cookie失效、API請求和統一認證。但還有一個場景無法滿足,那就是多終端資料共享。比如使用者在電腦上登入了一次,在手機上登入了一次,系統會生成2個token,這兩個token對應的使用者例項是不一樣的,所以使用者在電腦上設定的個性化資訊(比如性別,名稱)無法共享到手機上。
版本3:多終端資料共享
多終端共享需要明確兩點:
- 各個終端的登入時長互不影響
- 各個終端的使用者資料一致
實現多終端資料共享還有其他方法,下面舉例一個我在專案中用的方法。
程式碼如下:
/**
* 使用者登入
* @param string $login 登入名
* @param string $password 登入密碼
* @return UserModel|false
*/
function userLogin($login, $password) {
$user = UserModel::where('login', $login)->first();
if ($user && $user->checkPassword($password)) {
$token = $user->generateAuthToken();
session()->put('_token', $token);
// 認證
cache()->put('user_token_' . $token, $user->id);
// 資料
cache()->put('user_' . $user->id, $user);
return $user;
} else {
return false;
}
}
/**
* 獲取已經登入的使用者例項
* @return UserModel|null
*/
function getLoginUser($token = null) {
if (! $token) $token = session()->get('_token');
$token_cache_key = 'user_token_' . $token;
$user_id = cache()->get($token_cache_key);
if (! $user_id) return null; // token失效,認證過期
$user_cache_key = 'user_' . $user_id;
$user = cache()->get($user_cache_key);
if (! $user) {
// 快取失效,重新快取
$user = UserModel::find($user_id);
cache()->put($user_cache_key, $user);
}
return $user;
}
這種認證方式下,token
只能解析出user_id
,這就好比是一個使用者指標,系統再由user_id
解析出使用者例項
。這樣可以保證,不同終端拿到不同的token,這些token的過期時間不會相互影響,而不同token可以拿到同一個使用者資料,從而實現多終端使用者資料共享。
getLoginUser
函式,先檢查token
是否失效,再進一步檢查使用者例項
快取是否失效。
賬號啟用
多終端資料共享的應用場景也很廣泛,比如賬號啟用,發一份Email郵件,讓使用者點選連結進行賬號啟用。在啟用操作裡,系統需要知道使用者想要啟用那個賬號,一個通常的做法如下:
/**
* 生成用於啟用賬號的連結
* @return string 用於啟用的uri
*/
function generateActivateLink() {
$code = md5('activate' . Auth::id() . time());
cache()->put($code, Auth::id());
return url('/user/activate?code=' . $code);
}
/**
* 啟用使用者
* @param string $code 啟用碼
* @return string 用於啟用的uri
*/
function activateUser($code) {
$user_id = cache()->get($code);
if (! $user_id) return false;
// 修改資料庫
$user = UserModel::find($user_id);
$user->status = UserModel::STATUS_ACTIVATED;
$user->save();
// 修改快取
$user_cache_key = 'user_' . $user_id;
if (cache()->get($user_cache_key)) {
cache()->put($user_cache_key, $user);
}
return $user;
}
可以看到,生成的啟用連結中的code
其實是快取鍵,使用code
可以獲取到使用者id
,這樣系統就知道了需要啟用哪個使用者。
在啟用時,系統只需要修改快取中的使用者例項即可,使用者不需要重新登入賬號以重新整理快取中的資料。
第8行:url()
函式,是laravel中用於生成完整url的函式。
第21行:修改使用者的status
欄位值為STATUS_ACTIVATED
對應的值。
第22行:儲存修改的資訊到資料庫。
OAuth和第三方登入認證
OAuth協議可以讓第三方在不知道使用者敏感資訊的前提下,獲取伺服器內使用者的資源。第三方登入就可以使用OAuth協議來完成,如微信、QQ、微博等社交平臺都提供第三方登入接入服務。
OAuth2.0
OAuth2.0的授權可以簡單分為三步:
- 獲取使用者授權碼Code
- 獲取使用者授權令牌Token
- 使用授權令牌Token獲取使用者資訊
第一步,又稱使用者登入引導頁面。在微信登入時,這個頁面的域名是在微信下的,使用者同意授權後,微信會把授權碼Code送到伺服器(通過回撥URI的形式)。拿到這個Code表示使用者同意了授權
。
第二步,在微信登入時,這個token又叫access_token
。拿到這個Token表示伺服器是合法的
。
第三步,在微信登入時,這一步可以拿到使用者的open_id
。
在微信登入中,如果要獲取使用者基本資訊,需要用open_id
+access_token
才能得到。
如何整合
一個使用者可以”繫結”多個第三方賬號,這是一個比較好的處理第三方使用者的方式。第三方使用者的管理必須重視,如果管理混亂,繫結的資訊不能指向同一個使用者,就會出現多身份問題,比如使用者使用手機登入購買的東西,在使用微信登入時卻提示沒有購買。
我介紹一下我的做法,資料庫兩張表:
user
表,記錄使用者資訊。這裡有telephone
和email
等可用於登入的欄位user_third
表,記錄使用者繫結的第三方賬號資訊。
登入邏輯如下:
- 當用戶使用如手機號、郵箱、登入名登入時,在
user
表裡查詢資訊。 - 當用戶使用第三方登入時,系統先去
user_third
裡查詢資訊,如果未找到,則在user
表裡新建使用者,再將第三方賬號資訊儲存到user_third
裡,最後把新建的使用者與第三方賬號資訊繫結;如果能找到,則返回第三方賬號所繫結的user
表裡的資料。
這種做法,可以保證使用者資料均來自user
表,就不會有多身份問題,同時一個使用者也可以繫結多個第三方賬號,更加便於管理。
還有一種情況是繫結資訊衝突,比如使用者第一個賬號綁定了手機號和微信賬號,過段時間後,他用QQ賬號登入時(此時這個QQ號沒有對應系統內的使用者)系統會建立第二個賬號,此時他再去繫結手機號或微訊號的時候,會因為user
表的telephone
欄位、user_third
表中已有資訊,而導致繫結失敗。
處理這種情況常用的方法是解綁,使用者可以解綁QQ號,再繫結QQ號至第一次建立的賬號;也可以選擇解綁手機、微信,再將手機、微信綁到第二個賬號上。
單點登入
單點登入(Single Sign On,SSO)常用於多伺服器共存的大型網站,即一次使用者認證,即可訪問旗下所有網站。
以豆瓣網為例,它有豆瓣讀書、豆瓣電影子網站,這兩個子網站部署在不同伺服器上。
基於Token的認證
首先,使用者資料不能放在Session裡,所以基於Token的認證方式很快進入我們的視野,也就是版本2和版本3的認證方式。需要注意的是,不同伺服器必須使用同一個快取系統。可以單獨起一個伺服器用作資料儲存。這樣一來,系統都可以根據token
從快取系統中解析出使用者例項
。
同源:共享Cookie
仔細的同學會發現,版本3的token
是存在Session裡的,就算在子網A中登入完了,在子網B的Session中並沒有這個token
。一個常見的做法是共享Cookie,讓子網A的Cookie可以讓子網B使用,再將token
放在Cookie中,而不是放在Session裡。
例如豆瓣讀書域名為:book.douban.com,豆瓣電影域名為:movie.douban.com,現在要種一個Cookie,使得這兩個域名都能使用。因為他們是屬於同一個二級域名douban.com
下的,所以可以讓使用者在域名www.douban.com下登入,把Cookie的路徑設定為.douban.com
,即可實現Cookie的共享。
跨域:統一認證網站
如果遇到www.taobao.com和www.douban.com要做統一身份認證怎麼辦呢?因為沒有共同的二級域名,所以將認證系統建於第三個網站中,這個網站也叫統一認證網站(簡稱認證網)。
我們先假設一個未登入的使用者。
- 第一次請求。請求網站A的
/home
網頁,網站A檢測出使用者未登入,於是使用HTTP重定向,引導用於至認證網的登入頁面去。 - 第二次請求。這是由瀏覽器自主發起的,認證網響應出登入頁面。
- 第三次請求。使用者輸入賬號密碼進行登入,伺服器認證成功後,種下Cookie,並重定向至網站的A的
/home
頁面,但是帶上了token
。接收此次響應後,瀏覽器已有了認證網的Cookie,所以使用者在認證網處於登入狀態。 - 第四次請求。瀏覽器自主發起的,網站A必須識別出
token
引數,並儲存起來。在響應中,種下網站A的Cookie。此時使用者在網站A也處於登入狀態。
我們假設這個已經認證過的使用者,去訪問網站B。
可以看到,在引導使用者至認證網的登入頁面時,因為使用者在認證網處於登入狀態,所以認證網直接重定向到網站B的/profile
頁面。
有朋友會發現,認證網的功能其實可以融合到網站A或網站B中。確實可以這樣做,但是不推薦,因為要秉持低耦合的原則,將認證系統獨立出來會更加方便使用和管理。
進一步理解,使用OAuth協議也可以實現單點登入功能,它就是API版本的單點登入。
令牌管理
在基於令牌的認證裡,token
是最為關鍵的資訊,如果有第三方竊取到了使用者的token,他就可以冒充使用者的進行操作。
隱藏Token
啥意思呢?就是把token
放在HTTP頭裡,儘量讓使用者感覺不到token
的存在。比如下面的HTTP頭:
...
X-AUTH-TOKEN: 340c6f730612769b71075d4fbbe5d337
...
但是如果HTTP包被黑客獲取,他仍然能夠竊取到token
。
使用HTTPS
HTTPS會將資料包加密,所以黑客就算擷取到資料包到也無法獲取token
。