前后端分离用户身份状态保持的思考

发表于:2019-01-07 17:01:21,已有5263次阅读

1.废话连篇的前言

一直以来我都是做传统WEB开发的,这些年的开发经验,对于传统WEB开发应该来说还算得心应手。最近由于客户的需求,需要做微信小程序的开发,刚开始觉得真没有什么,不就是前后端分离加上一个“新皮肤”(UI)而已吗,有什么大不了的?可是做着做着就发现了与传统的WEB开发有着一个本质的区别,我操!用户的状态信息该怎么保存呢?以前默认的Session会话管理彻底算是废了,后端的开放接口是无状态的!这时候我就在思考该使用一个什么样的机制来实现后端开放接口的用户身份状态的保持。
其实结合Session的原理我们可以知道,传统的WEB开发,在保持用户状态的时候,使用的是Session ID实现的:在浏览器第一次请求的时候给其生成一个Session ID(放在Cookie里),然后浏览器再在接下来的每一次请求中带上这个Session ID传给后端,而后端再根据这个Session ID检索对应文件系统查找对应的用户状态信息(默认Session数据是直接保存在服务端的文件系统里的),从而实现了用户身份状态的保持。这部分功能只是现行浏览器给包干了,传统后端开发久了后,让我习惯性忽略了,而且浏览器设置的一些Session规则(如Domain、Secure、HttpOnly等)也保证了用户信息的安全。因此前后端分离后,这部分功能将由我们自己来实现。

2.流程图及细节思考

很快整理一下思路,画一下对应的流程图:

用户身份状态验证流程图

整体的流程大致就是这样的,但实现方面还要注意一些细节:

这个Token应该以什么方式或者规则来生成呢?又怎么以Token来对应对应的用户信息呢?

这里有两个思路可以实现:

  1. Token即是用户的key,后端服务器使用缓存将key与对应的用户信息关联,这样的功能,正好Memecahed或Redis等非关系缓存服务可以实现(当然传统的关系数据库如mysql也可以实现,只是没有它们更加的贴合与便捷),即前端与后端之间的身份状态信息传递只传key,后端再由key查询公共的缓存服务获取用户信息,信息的存储压力在后端。

  2. Token即是用户数据,后端服务器直接将登录成功的用户信息,通过JSON等特定格式组织编码后直接传给前端,前端再在接收后,将这个编码后的Token存入Cookie或HTML5支持的Web Storage缓存下,再在每次调用后端API时,将其传给后端,后端再解码直接由Token得到对应的用户信息,即信息的存储压力在前端。

我们可以使用表格直观的对比一下这两种方案各自的特点:

特点\方案方案1方案2
安全性及可控性由于前端后端之间只传递KEY,敏感信息存放在后端,故相对而言方案1的安全性和可控性较由于前端后端之间直接传递的是用户信息,信息被暴露于网络间,故相对而言方案2的安全性和可控性较
系统负载及扩展性由于用户信息存储在后端,后端在面临大规模的请求处理时还要额外的构建专门的服务器进行用户状态信息管理,故而对后端的负载要求较,扩展时较由于前端负责存储用户数据,后端真正的做到了无状态,相当于将负载分摊到了每个用户,所谓“无官一身轻”,对于系统的负载相对要求较,扩展时较
技术实现难度后端管理数据,多机器分布式管理时,还要考虑数据的同步问题,故而技术实现难度较这里只要处理好数据的编码与解码,技术实现难度

比较上面的两种方案的实现,对于用户数据请求量不多,为了敏捷开发和考虑开发难度及成本问题,在这里我们重点的介绍方案2的实现(方案1较难,我会说我只捡容易的来弄吗?哈哈)。

3. JSON WEB TOKEN横空出世

接上,要实现数据在前端与后端之间的相互传递,有什么好的协议框架呢?首先我们可能会想到Cookie,使用Cookie直接的在前端与后端之间传递,但Cookie有一个限制:Cookie是不允许跨域访问的。而JSON WEB TOKEN就这样横空出世(简称JWT),它正是为了解决这样问题的代表方案。

3.1 JWT长什么样

为了更加直观的感受下JWT是什么,我们可以直接的访问他的官网:https://jwt.io/ 来看一下究竟JWT长什么样。

首先来看它编码后的样子:
JWT Encoded
很直观的感受,它是由三部分组成的,以.号分割。如上图所示,不同的颜色代表不同的部分。

接下来看一下它被解码后的样子:
JWT Dencoded
很明显的三部分组成。

Header 头部,用于声明数据采用的加密方式和类型
Payload 负载,用于存放主要的数据,官方也定义了一些可选的属性,如过期时间等。
Signature 签名,用于接收方验证数据是否没有被篡改。

在这里总结一下JWT的组成,其实JWT的主要数据存放在Payload中,在它里面可以自定义任意的数据。后端通过解码后,主要使用的也是这里面的数据,而其他的两个部分都是安全性辅助,是为了防止JWT在网络传输过程中被第三方截获篡改。

3.2 JWT的编码规则

知道了JWT的组成,接下来就来看一下JWT的编码规则。在使用JWT的过程中,我们需要先弄明白前后端之间到底要传递什么样的数据,这样我们就能确定Payload部分的数据。

在这里我们假设前端只需要传递username数据,为了安全性再加上JWT官方定义的过期时间exp签发时间iat,则可以确定Payload为这样:

{
  "username": "ZQLUO",
  "exp": 1546923164,
  "iat": 1546915964
}

Payload使用Base64URL算法编码后为:

Base64URL(Payload) = eyJ1c2VybmFtZSI6IlpRTFVPIiwiZXhwIjoxNTQ2OTIzMTY0LCJpYXQiOjE1NDY5MTU5NjR9

这里说明一下何为Base64URL?:

这个算法跟Base64算法基本类似,但有一些小的不同。
JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+、/和=,在URL里面有特殊含义,所以要被替换掉:=被省略、+替换成-/替换成_ 。这就是Base64URL算法。

再来使用Header声明一下Signature签名的加密方式为默认的HS256,及数据类型为jwt,则可以确定Header

{
  "alg": "HS256",
  "typ": "JWT"
}

同样Header也是通过Base64URL算法编码:

Base64URL(Header) = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

最后加上一个加密的字符串123456结合Header声明的加密算法SHA256,得到Signature

HMACSHA256(Base64URL(header) + "." + Base64URL(payload), 123456) = hGkR1oqXIklYDukMRVGSJyrDsydGxvU85whT_ACKFSw

最后将这三部分使用.号连接,就是完整的JWT编码字符:

Base64URL(Header) + "." + Base64URL(Payload) + "." + HMACSHA256(Base64URL(header) + "." + Base64URL(payload), 123456) 
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IlpRTFVPIiwiZXhwIjoxNTQ2OTIzMTY0LCJpYXQiOjE1NDY5MTU5NjR9.hGkR1oqXIklYDukMRVGSJyrDsydGxvU85whT_ACKFSw

说了这么多是不是感觉很复杂,其实还好啦!JWT作为一款通用受欢迎的协议,在他的官网我们可以发现很多实现他的类库,支持的编程语言繁多,完全不用考虑具体的编码验证实现,十分的方便!点击https://jwt.io/中的Libraries标签查看。

3.3 JWT的安全性考虑

由上面我们可以看到,默认情况下Payload中的数据是没有加密的,就简单的使用Base64URL解码了一下,这样子可以很容易的还原看到里面的原始数据,因此,采用默认的JWT编码方式,Payload中是不能存放敏感数据的,比如用户的手机号等等。

为了进一步提升JWT传递的安全性,我们可以选择对Payload中的数据进行加密,然后再在后端对其进行解密。很熟悉的,让我们想到了非对称加密的RSA算法,即通过生成的私钥加密,公钥解密。

4.JWT的编码实现及踩过的坑

由于这些年一直操的都是PHP,以熟悉为本,在这里来讲解一下PHP如何实现JWT。

在这里使用JWT PHP的基础库为firebase/php-jwt, 签名Signature的产生也是采用RSA 256加密,而非默认的SHA256(提升安全性):

<?php
use \Firebase\JWT\JWT;

// openssl生成的私钥
$privateKey = <<<EOD
-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQC8kGa1pSjbSYZVebtTRBLxBz5H4i2p/llLCrEeQhta5kaQu/Rn
vuER4W8oDH3+3iuIYW4VQAzyqFpwuzjkDI+17t5t0tyazyZ8JXw+KgXTxldMPEL9
5+qVhgXvwtihXC1c5oGbRlEDvDF6Sa53rcFVsYJ4ehde/zUxo6UvS7UrBQIDAQAB
AoGAb/MXV46XxCFRxNuB8LyAtmLDgi/xRnTAlMHjSACddwkyKem8//8eZtw9fzxz
bWZ/1/doQOuHBGYZU8aDzzj59FZ78dyzNFoF91hbvZKkg+6wGyd/LrGVEB+Xre0J
Nil0GReM2AHDNZUYRv+HYJPIOrB0CRczLQsgFJ8K6aAD6F0CQQDzbpjYdx10qgK1
cP59UHiHjPZYC0loEsk7s+hUmT3QHerAQJMZWC11Qrn2N+ybwwNblDKv+s5qgMQ5
5tNoQ9IfAkEAxkyffU6ythpg/H0Ixe1I2rd0GbF05biIzO/i77Det3n4YsJVlDck
ZkcvY3SK2iRIL4c9yY6hlIhs+K9wXTtGWwJBAO9Dskl48mO7woPR9uD22jDpNSwe
k90OMepTjzSvlhjbfuPN1IdhqvSJTDychRwn1kIJ7LQZgQ8fVz9OCFZ/6qMCQGOb
qaGwHmUK6xzpUbbacnYrIM6nLSkXgOAwv7XXCojvY614ILTK3iXiLBOxPu5Eu13k
eUz9sHyD6vkgZzjtxXECQAkp4Xerf5TGfQXGXhxIX52yH+N2LtujCdkQZjXAsGdm
B2zNzvrlgRmgBrklMTrMYgm1NPcW+bRLGcwgW2PTvNM=
-----END RSA PRIVATE KEY-----
EOD;

// openssl生成的公钥
$publicKey = <<<EOD
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC8kGa1pSjbSYZVebtTRBLxBz5H
4i2p/llLCrEeQhta5kaQu/RnvuER4W8oDH3+3iuIYW4VQAzyqFpwuzjkDI+17t5t
0tyazyZ8JXw+KgXTxldMPEL95+qVhgXvwtihXC1c5oGbRlEDvDF6Sa53rcFVsYJ4
ehde/zUxo6UvS7UrBQIDAQAB
-----END PUBLIC KEY-----
EOD;

$token = array(
    "username"=> "ZQLUO",
    "exp"=> 1546923164,
    "iat"=> 1546915964
);

$jwt = JWT::encode($token, $privateKey, 'RS256');
echo "Encode:\n" . print_r($jwt, true) . "\n";

$decoded = JWT::decode($jwt, $publicKey, array('RS256'));

/*
 注意: 这里解码得到的$decoded是一对象,在这里将其直接的转化为array数组,
  如果有需要你需要自己实现这一转化,这里简化:
*/

$decoded_array = (array) $decoded;
echo "Decode:\n" . print_r($decoded_array, true) . "\n";
?>

使用openssl生成的私钥、公钥的命令如下:

# 生成私钥
openssl genrsa -out rsa_private_key.pem 1024
# 转换格式
openssl pkcs8 -topk8 -inform PEM -in rsa_private_key.pem -outform PEM -nocrypt -out private_key.pem
# 生成公钥
openssl rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem

如上面说的由于Payload中的username字段只是简单的使用Base46URL编码,直接的暴露在网络上是很不安全的,故而在这里可以根据需要对username进行RSA加密,涉及的PHP加密方法为openssl_public_encrypt,解密方法为openssl_private_decrypt。这里使用的私钥与公钥可以再使用openssl重新生成一对。

踩过的坑:如果这里的username是一些其他的多字符数据时,当采用1024 bit的方式生成私钥和公钥,则加密的明文超过117个字符后,openssl_public_encrypt就会返回false!!
同样采用2048 bit的方式生成的私钥与公钥对,加密的明文不能超过245个字符:2048/8 - 11(when padding used) = 245 chars (bytes),引用:

值得注意的是,如果选择密钥是1024bit长的(openssl genrsa -out rsa_private_key.pem 1024),那么支持加密的明文长度字节最多只能是1024/8=128byte;
如果加密的padding填充方式选择的是OPENSSL_PKCS1_PADDING(这个要占用11个字节),那么明文长度最多只能就是128-11=117字节。如果超出,那么这些openssl加解密函数会返回false。

这时有个解决办法,把需要加密的源字符串按少于117个长度分开为几组,在解密的时候以172个字节分为几组。

其中的『少于117』(只要不大于117即可)和『172』两个数字是怎么来的,值得一说。
为什么少于117就行,因为rsa encrypt后的字节长度是固定的,就是密钥长1024bit/8=128byte。因此只要encrypt不返回false,即只要不大于117个字节,那么返回加密后的都是128byte。
172是因为什么?因为128个字节base64_encode后的长度固定是172。
这里顺便普及下base64_encode。encode的长度是和原文长度有个计算公式:
$len2 = $len1%3 > 0 ? (floor($len1/3)*4 + 4) : ($len1*4/3);

如下,将超过117的字符分组进行RSA加密,最后拼接后统一使用base64编码,对应的加密解密方式如下:

<?php
// 加密
function encrypt($originalData){
    $crypto = '';
    foreach (str_split($originalData, 117) as $chunk) {
        openssl_public_encrypt($chunk, $encryptData, $this->rsaPublicKey);
        $crypto .= $encryptData;
    }

    return base64_encode($crypto);
}
// 解密
function decrypt($encryptData){
    $crypto = '';
    foreach (str_split(base64_decode($encryptData), 128) as $chunk) {
        openssl_private_decrypt($chunk, $decryptData, $this->rsaPrivateKey);
        $crypto .= $decryptData;
    }

    return $crypto;
}
?>

如果,分组进行RSA加密后,直接再使用base64编码得到分组字符,最后再将编码后的分组字符连接再一起的方式,则采用如下方式加密和解密:

<?php
// 加密
function encrypt($data, $rsa_publickey){
     // 1024 bit && OPENSSL_PKCS1_PADDING  不大于117即可
    $split = str_split($data, 117);
    $crypto = '';
    foreach ($split as $chunk) {
        $isOkay = openssl_public_encrypt($chunk, $encryptData, $rsa_publickey);
        if(!$isOkay){
            return false;
        }
        $crypto .= base64_encode($encryptData);
    }
    return $crypto;
}
// 解密
function decrypt($data, $rsa_privatekey){
    // 128个字符(1024 bit)base64编码为172定长
    $split = str_split($data, 172);
    $crypto = '';
    foreach ($split as $chunk) {
        // base64在这里使用,因为172字节是一组,是encode来的
        $isOkay = openssl_private_decrypt(base64_decode($chunk), $decryptData, $rsa_privatekey);
        if(!$isOkay){
            return false;
        }
        $crypto .= $decryptData;
    }
    return $crypto;
}
?>

5.最后的最后

以上就是关于JWT在前后端分离用户身份状态保持中的应用,在使用过程中可以将生成的token通过传参的方式直接的在前后端进行传递,后端负责更新,前端只传递。可以考虑对AJAX请求进行一次自定义封装,在需要使用AJAX请求的时候直接调用公共的封装方法进行请求,而在公共方法里进行统一的token存取更新传递管理等等。

参考链接:

JSON Web Token 入门教程
php使用openssl进行Rsa长数据加密(117)解密(128)
RSA加密解密(无数据大小限制,php、go、java互通实现)

评论

点赞(广东中国深圳)发表于:2019-08-13 00:17:25回复 

文章很好,值得学习

您还可输入120个字