前言
用户认证就是判断一个用户是否为合法用户的过程。
目前用户认证大都是基于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
才能得到。
关于OAuth2.0协议更多内容,可以参考这2篇文章:深入理解OAuth2.0协议 ,理解OAuth 2.0
如何集成
一个用户可以”绑定”多个第三方账号,这是一个比较好的处理第三方用户的方式。第三方用户的管理必须重视,如果管理混乱,绑定的信息不能指向同一个用户,就会出现多身份问题,比如用户使用手机登录购买的东西,在使用微信登录时却提示没有购买。
我介绍一下我的做法,数据库两张表:
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
。