不管用哪种方式认证用户,都可能被中间人攻击窃取 SessionID 或 Token,从而发生 CSRF 攻击。解决方式就是全站 HTTPS。现在 Let’s Encrypt 已经支持免费的通配符 HTTPS 证书了。
HTTP 协议是无状态的,要保存用户状态需要额外的机制。
刚开始时,多数公司使用的技术栈是:单台云服务器上安装所需的所有软件,包括 Nginx 提供 Web 服务,MySQL 数据库,PHP-FPM 应用程序服务。这时候使用的用户认证协议使用最简单的 Session。客户端的每个请求都会携带 Cookie,其中保存了 SessionID 字段,服务器可以通过这个 SessionID 字段访问到对应的 Session(例如 PHP 中的 $_SESSION
),从而识别出用户登录状态。Session 中还可以添加一些常用的字段进来(比如用户名、手机号等),避免对数据库的频繁访问。
后来,随着用户量增大、并发增大,单台服务器搞不定了,于是搞了个水平扩展的服务器集群,通过 Nginx 或 LVS 实现负载均衡。这时发现个问题,用户登录后 Session 是保存到集群中的某一台服务器上的。要使 Session 机制可以在分布式环境下继续工作,需要一些额外操作。而且对于现在的大前端(浏览器、APP、小程序)趋势来说,Cookie 机制略显累赘。
而这时,JWT 认证协议完全满足需求。协议简单清晰,花一个下午就可以搞清楚。
公司发展过程中,产品线会慢慢增多,比如百度的贴吧、网盘、浏览器等。这时,需要一套单点登录机制 SSO(Single sign-on),用户只要一次登录,就可以使用这一系列产品。SSO 描述了认证的问题。
SSO 需要一个独立的认证中心 CAS(Central Authentication Service,中央认证服务),只有认证中心能提供登录入口,接受用户的用户名密码等凭证,其他系统无登录入口,只接受认证中心的间接授权。这里有个开源的 CAS:apereo CAS,其服务端用 Java 实现,客户端支持多种语言。其架构文档可以参考 这里。
单体项目拆分成微服务后,可以更加灵活。通常所有的服务都在网关之后,所有请求都发送到网关,由网关统一转发。微服务的网关通常实现了 OAuth,成为认证授权中心,用于判断是否有足够权限。微服务之间可以通过 JWT 进行访问鉴权,避免身份认证。
随着公司用户增多(假设跟微信一样,有几亿用户),合作企业也越来越多。如果每次都要在后台通过人工给合作伙伴配置账号密码,分配权限管理,那太麻烦了。同时,一些企业有自己的平台,想要利用我的用户账号体系实现在这些平台上的登录(授权登录)。对于用户的图片,一些图片打印公司也想在经过用户同意后,直接访问到我服务器上的用户图片,优化体验。
总之,就是只要用户同意,他可以分享自己的所有资源(账号、图片等)。这时,就需要 OAuth2 了。这是一个授权框架,描述了各种授权的问题。
例如,用户登录论坛时,需要先用用户名和密码认证用户有没有权限登录,如果密码正确则认证通过,登录成功。用户登录后,判断其角色并授予相应的权限,例如超级管理员可以删除所有人,版主可以删除其版块的帖子。
最传统的用户认证方式。用户首次访问应用服务器后建立会话,服务器可以使用 Set-Cookie 这个 HTTP Header,将会话的 SessionID 写入在用户端保存的 Cookie 中(具体的名字可以自行设置,系统中统一即可)。下次用户再次向这个域名发请求时会携带所有 Cookie 信息,包括这个 SessionID。
Session 信息保存在服务器端,而用于唯一标识这个 Session 的 SessionID 则保存在对应客户端的 Cookie 中。SessionID 这个会话标识符本质上是一个随机字符串,每个用户的 SessionID 都不一样。
Session 中可以保存很多信息。例如设置一个 IsLogin 字段,用户通过账号密码登录后,将这个字段设置为 TRUE。这样,在 Session 的有效期内(比如 2 小时),即使用户关闭网页,再次打开后仍会保持登录状态(除非用户清理了 Cookie,导致其访问服务器时没有携带 SessionID 字段)。对于其他的常用字段(如 userID、userName等)也可以添加到 Session 中,以减少数据库的访问压力,但注意不要太大,因为所有用户的会话信息都是保存在服务器的内存中的。
下面的示例,基于 PHP 语言,CodeIgniter 框架。同时,省略了无关的 HTTP Header,重点分析 Session 相关字段。
在第一次访问一个网站时,浏览器中没有对应 Cookie 信息,所有请求的 HTTP Header 中没有 Cookie 这个字段。如果应用服务器支持会话,可以在为这个用户创建 Session 后,通过在响应的 HTTP Header 中使用 Set-Cookie 字段将这个会话的 SessionID 保存到浏览器的 Cookie 中。可以看到我这里对应的 SessionID 的名字是 ci_session:
-----------------------------------------请求的 HTTP Header-----------------------------------------
GET http://tuan.local.cn/ HTTP/1.1
Host: tuan.local.cn
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
If-Modified-Since: Thu, 10 May 2018 06:20:36 GMT
...
-----------------------------------------响应的 HTTP Header-----------------------------------------
HTTP/1.1 200 OK
Date: Thu, 10 May 2018 06:21:13 GMT
Content-Type: text/html; charset=UTF-8
Set-Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf; expires=Thu, 10-May-2018 08:21:13 GMT; Max-Age=7200; path=/; HttpOnly
...
这里 Set-Cookie 中的各个字段解释如下,完整的中文版解释参考 这里:
Document.cookie
属性或 XMLHttpRequest 和 Request 这两个 API 访问,避免 XSS(cross-site scripting,跨站脚本攻击)。每次通过域名或 IP 地址访问时,浏览器都会检查是否有可用的 Cookie,如果有,则放到请求的 HTTP Header 中一同发送到服务器:
-----------------------------------------请求的 HTTP Header-------------------------------------------
GET http://tuan.local.cn/ HTTP/1.1
Host: tuan.local.cn
Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf
...
-----------------------------------------响应的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Thu, 10 May 2018 06:22:02 GMT
Content-Type: text/html; charset=UTF-8
...
登录成功之后,登录请求对应的响应会再次设置 Cookie 字段,重新设置 Cookie 字段的有效期。我的应用程序中设置 Session 为两个小时的有效期:
这里演示的是通过 AJAX 登录,所以有 Origin 和 X-Requested-With 这两个由浏览器自动设置的字段:
-----------------------------------------请求的 HTTP Header-------------------------------------------
POST http://tuan.local.cn/index/login_password HTTP/1.1
Host: tuan.local.cn
Origin: http://tuan.local.cn
X-Requested-With: XMLHttpRequest
Content-Type: application/json
Referer: http://tuan.local.cn/
Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf
...
{"Mobile":"18866668888","Password":"888666"}
-----------------------------------------响应的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Set-Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf; expires=Thu, 10-May-2018 08:22:33 GMT; Max-Age=7200; path=/; HttpOnly
...
跟正常访问没有区别,只是携带的 Cookie 中有 SessionID,且服务器端对应的 Session 中需要(比如 IsLogin=true,自己设置)标识已登录状态:
-----------------------------------------请求的 HTTP Header-------------------------------------------
GET http://tuan.local.cn/ HTTP/1.1
Host: tuan.local.cn
Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf
...
-----------------------------------------响应的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Thu, 10 May 2018 06:22:34 GMT
...
Session 的主要问题有:
还有,就是目前大前端的发展,除了浏览器外,各种 APP、小程序层出不穷,而非浏览器下环境下避免使用 Cookie 可能会更简单。
JWT 官网的详细介绍
Larval + Vue 案例
Session 之所以这么麻烦,是因为需要在服务器端保存信息,那我把信息保存在客户端,不就可以避免这个麻烦了嘛。JWT 就是这么个思路,服务器端保存加密机制及密钥,对用户指定字段进行加密后的字符串保存在客户端,用户下次请求时携带加密前的字段和加密后的字符串,如果跟服务器加密结果匹配,则认为登录成功。
JWT(JSON web token)是一种认证协议,可以发布接入令牌(Access Token,保持在客户端)并对发布的签名接入令牌进行验证。令牌(Token)本身包含一系列声明,应用程序可以根据这些声明限制用户对资源的访问。
JWT 由三段信息构成的:
JWT 示例:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjU5NDM4MTksIm5iZiI6MTUyNTk0Mzg3OSwiZXhwIjoxNTI1OTQ3NDE5LCJ1aWQiOjF9.jL-Hrl8obZlLGutjr-nVPCSoF2ObFh-rWfSwSZxoxzs
Header 部分用于声明协议类型和加密方式。
上面的 JWT 示例的 header 部分经过 base64_decode 后得到原始 JSON 字符串,内容如下:
{
"typ":"JWT",
"alg":"HS256",
"jti":"4f1g23a12aa"
}
其中,typ 内容固定为 JWT,alg 表示加密算法,这里使用的是 HMAC SHA256。
payload 部分用于存放负载,将明文信息经过 base64 编码后存储,未经加密,不可存储敏感信息。包括以下三种:
JWT 标准中注册的声明(不强制使用)有以下几种,完整版可以 参考这里:
上面 JWT 示例中的 payload 部分对应的 JSON 字符串为:
{
"iss":"http:\/\/example.com",
"aud":"http:\/\/example.org",
"jti":"4f1g23a12aa",
"iat":1525943995,
"nbf":1525944055,
"exp":1525947595,
"userID":6666,
"userName":"kika",
"userSex":"m"
}
这个 payload 中添加了几个自定义字段。
将 header 和 payload 经过 base64 编码后,用 .
句点拼接成一个字符串,通过 HMACSHA256(Java 的方法)或 hash_hmac(PHP 的方法),使用指定密钥加密这个字符串得到 signature。
JAVA:
sig = HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret);
PHP:
$sig = hash_hmac('sha256', base64_encode($header) + "." + base64_decode($payload), $secret);
JWT 支持两种签名方式:
用户登陆后,可以把一些常用字段(用户标识,是否是管理员,权限有哪些等等可以公开的信息)用 JWT 编码存储在 Cookie 中,每次服务器读取到 Cookie 后就可以解析到当前用户对应的信息,减小数据库压力。也可以用 Authorization: Bearer
的方式通过 HTTP Header 仅发送 JWT 的 Token。
发送请求时,Token 放在请求的 HTTP Header 中。另外,如果发生跨域,例如 www.xx.com
下发出到 api.xx.com
的请求,需要在服务端开启 CORS(跨域资源共享):
Access-Control-Allow-Origin: *
下面的示例,基于 PHP 语言,CodeIgniter 框架。同时,省略了无关的 HTTP Header,重点分析 JWT 相关字段。
-----------------------------------------请求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/create_token HTTP/1.1
Host: jwt.com
...
-----------------------------------------响应的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Fri, 11 May 2018 02:27:19 GMT
Set-Cookie: jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDU2MzksIm5iZiI6MTUyNjAwNTY5OSwiZXhwIjoxNTI2MDA5MjM5LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.MvYG6L71mM_AJj5FT4--RzCluIQ__nqgYSe9RTj8VCk; expires=Fri, 11-May-2018 04:27:19 GMT; Max-Age=7200; path=/
Content-Length: 1052
...
服务器端从 Cookie 中提取 jwt 这个字段后验证签名,如果通过验证则认为内容可靠,解析其中的内容并以此决定用户登录状态、权限等:
-----------------------------------------请求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/ HTTP/1.1
Host: jwt.com
Cookie: jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDU2MzksIm5iZiI6MTUyNjAwNTY5OSwiZXhwIjoxNTI2MDA5MjM5LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.MvYG6L71mM_AJj5FT4--RzCluIQ__nqgYSe9RTj8VCk
...
-----------------------------------------请求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/create_token HTTP/1.1
Host: jwt.com
...
-----------------------------------------响应的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Fri, 11 May 2018 02:35:19 GMT
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDc3NTQsIm5iZiI6MTUyNjAwNzgxNCwiZXhwIjoxNTI2MDExMzU0LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.CBsw7_rDC-GeJBob2JwCOITp7L80g_VT9KUtSVmKYKY
Host: jwt.com
-----------------------------------------请求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/ HTTP/1.1
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDc3NTQsIm5iZiI6MTUyNjAwNzgxNCwiZXhwIjoxNTI2MDExMzU0LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.CBsw7_rDC-GeJBob2JwCOITp7L80g_VT9KUtSVmKYKY
Host: jwt.com
后端服务器对这个 Authorization 进行判断即可。
对于 PHP,可以使用的 JWT 库有 jwt、jwt-auth。这里以第一个 jwt 为例,具体操作请结合所使用语言及框架和安装的 JWT 库。
composer require lcobucci/jwt
注意,PHP 版本需要 5.5+,同时需要开启 OpenSSL 扩展。
use Lcobucci\JWT\Parser;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Hmac\Sha256;
public function create_token() {
$builder = new Builder();
$signer = new Sha256();
// 设置签发者
$builder->setIssuer('http://xx.com');
// 设置接收者
$builder->setAudience('http://xx.com');
// 设置 ID,可以用来区分
$builder->setId('4f1g23a12aa', true);
// 设置签发时间
$builder->setIssuedAt(time());
// 在 60 秒内该 token 无法使用
$builder->setNotBefore(time() + 60);
// 设置过期时间位 2 小时
$builder->setExpiration(time() + 7200);
// 设置自定义的 payload 信息
$builder->set('userID', 6666);
$builder->set('userName', 'kika');
$builder->set('userSex', 'm');
// sha256 签名,密钥字符串可以自定义
$builder->sign($signer, 'signatureString');
// 获取生成的token
$token = $builder->getToken();
// 可以通过 Cookie 传输
set_cookie('jwt', $token, 7200);
// 也可以通过 HTTP Header 传输,在前端保存 token 后添加到 HTTP Header 即可:Authorization: Bearer xx.xx.xx
// 查看字段内容
$token = explode('.', $token);
echo base64_decode($token[0]).'
';
echo base64_decode($token[1]).'
';
}
把上面使用字符串加密的这一行:
$builder->sign($signer, 'signatureString');
替换为使用密钥文件加密即可,需要提供私钥地址:
$builder->sign($signer, $keychain->getPrivateKey('私钥地址'));
在每一个请求头里加入 Authorization,并加上 Bearer:
fetch('api/user', {
headers: {
'Authorization': 'Bearer ' + token
}
})
通过 Cookie 传输 JWT 信息:
if ($token = get_cookie('jwt')) {
$rs = $this->verify_token($token);
if ($rs) {
echo 'you have right jwt
';
} else {
echo 'error
';
}
}
通过 HTTP Header 传输 JWT 信息:
$headers = apache_request_headers();
if (!empty($headers['Authorization']) && $token = $headers['Authorization']) {
$token = substr($token, strpos($token, 'Bearer ') + 7);
$rs = $this->verify_token($token);
if ($rs) {
echo 'you have right jwt from Authorization
';
} else {
echo 'error Authorization
';
}
}
直接从 $token
中获取所有数据:
public function get_claims ($token) {
$parser = new Parser();
$parse = $parser->parse($token);
return $parse->getClaims();
}
也可以获取单条数据:
$parse->getClaim('aud');
内容比较多,另写一篇,参考 这里。