Bootstrap

JWT实现接口双重认证,提供安全又不复杂的接口安全能力

首先,JWT的描述:

太多地方有关于它的描述了,这里就不赘叙了,直接重点JWT(Json web token),主要应用场景是解决分布式站点的单点登录(SSO),同时传递一些非敏感性的数据。

官网:JSON Web Tokens - jwt.ioJSON Web Token (JWT) is a compact URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is digitally signed using JSON Web Signature (JWS).https://jwt.io/

顺带说下session

web应用大部分都是采用http/https无状态的协议进行传输数据,也就是说每次的请求都需要识别是哪个用户发出的请求,而session在用户输入账号密码后(非必须)就会在服务器生成基于浏览器用户登录的信息,并存储在服务端,然后响应回客户端存储在cookie中,客户端每次请求都会从cookie中读取session传递给服务器进行业务操作,具体流程如下:

 session对于小站点来说是非常便捷的,但是应用中大型web应用就会有一定的弊端:

存储消耗: 每个客户端经过服务器响应之后,web应用都要在服务端保存一个session文件,以方便客户端下次请求的检测,通常而言session都是保存在内存中,随着访问用户的增多,服务端的开销就会明显增大。

分布式拓展:   由于当客户端首次访问服务器后都会生成session,并仅能存储在一个服务器上,当我们进行多台服务器做分布式时就会导致刚才登录完的用户又需要重新登录。当然这也有解决方案就是采用统一独立的session服务器,但是这个配置过程是复杂的,这就限制了分布式的拓展能力。同时还有分布式带来的单点登录问题,也是无法通过session来简单解决的。

CSRF: 因为是基于cookie来进行session识别的, cookie如果被截获(其实真的很容易),用户就会很容易受到跨站请求伪造的攻击。

还有Token的机制

http通过token识别也是无状态的,无需在服务器端保存用户的登录数据,也就是说不需要考虑用户在哪台服务器上登录了,只要客户端带上token,每个服务器都能识别出该用户的数据。

但是!每次的访问都需要服务器通过数据库对Token进行比对,无形之中就会增大数据库的压力。

至于怎么生成token那方式就五花八门了,常见的有md5、base64、uuid等。相比session而言有一定的进步,一是拓展,二是不一定需要cookie。

也大概展示下具体流程:

 JWT真实样子

我们回到正题,先看下JWT最终成型的样子:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJocmciLCJhdWQiOiJsb2NhbGhvc3QiLCJpYXQiOjE2NDc5MzMzMDAsIm5iZiI6MTY0NzkzMzMwMCwiZXhwIjoxNjQ3OTQwNTAwLCJqdGkiOiJiYTliZjA0YTQ5NGNlN2U1OTFhOThkZjlhY2Q2NzJkYyIsImFwcGtleSI6IkU2QTdFMkQ1RTAwQTY3QUVDMjY3QkQ5MzVFNzYzNkIyIn0.UND6YbUggeLGx3vLiB3uL2Fm-HKPjedRbvfdaeJRNF0

通过上面的字符串,我们能看到2个点将整个JWT分成了3部分 (header.payload.signature),这3部分分别对应是header(头部)、payload(有效载荷)、signature(签证),下面我们就开始详细讲解下这3个部分分别有什么作用,如何生成等。

1、header(头部)-声明定义

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

 这是加密后的样子,主要是通过base64进行编码。

*(其实严格来说base64不是加密,这里说的加密是相对整体而言的。)

我们再来看下原始内容:

$header = array(
    'alg' => 'HS256',  //生成signature的算法
    'typ' => 'JWT'     //类型
);

JWT的header主要有两部分内容

①声明类型 typ :一般默认都是jwt

②声明加密方式 alg :一般默认是HMAC SHA256

生成方式(以PHP展示):

// 通过json_encode把array转成json,然后通过base64_encode转成base64
$jwt_header = base64_encode(json_encode($header,JSON_UNESCAPED_UNICODE));

2、payload(有效载荷)-存放有效信息

eyJpc3MiOiJocmciLCJhdWQiOiJsb2NhbGhvc3QiLCJpYXQiOjE2NDc5MzMzMDAsIm5iZiI6MTY0NzkzMzMwMCwiZXhwIjoxNjQ3OTQwNTAwLCJqdGkiOiJiYTliZjA0YTQ5NGNlN2U1OTFhOThkZjlhY2Q2NzJkYyIsImFwcGtleSI6IkU2QTdFMkQ1RTAwQTY3QUVDMjY3QkQ5MzVFNzYzNkIyIn0

有效载荷主要是存放有效信息,一般可以分为2种信息:标准信息、公共信息。

标准信息:(并不完全强制要求全部使用)

  • iss    : jwt签发者
  • sub  : 主题名称
  • aud  : 面向的用户,一般都是通过ip或者域名控制
  • exp  : jwt的有效时间(过期时间),这个有效时间必须要大于签发时间,对于交互接口来说,建议是预设5秒
  • nbf   : 在什么时候jwt开始生效(在此之前不可用)
  • iat    : jwt的签发时间
  • jti     : 唯一标识,主要用来回避被重复使用攻击

公共信息:(明文信息)

公共信息一般主要是用于存放用户相关或者业务需要的信息,但是不能存放敏感信息或者用户数据。

然后我们来看下上面有效载荷的原始内容:

$payload = [
    //签发者 可以为空
    "iss"	=> ( isset($param['iss'])&&!empty($param['iss']) ) ? $param['iss'] : "",  

    //面象的用户,可以为空
    "aud"	=>isset($param['aud'])?$param['aud']:"localhost;", 

    //签发时间       
    "iat" 	=> ( isset($param['iat'])&&!empty($param['iat']) ) ? $param['iat'] : time(), 

    //在什么时候jwt开始生效 
    "nbf" 	=> ( isset($param['nbf'])&&!empty($param['nbf']) ) ? $param['nbf'] : time(), 

    //token 过期时间
    "exp" 	=> ( isset($param['exp'])&&!empty($param['exp']) ) ? $param['exp'] : time()+5, 
    
    // 唯一标识        
    "jti" 	=> ( isset($param['jti'])&&!empty($param['jti']) ) ? $param['jti'] : md5(uniqid('JWT').time()),

    //签发者密钥-业务信息
    "appkey" => ( isset($param['appkey'])&&!empty($param['appkey']) )?$param['appkey']:'',
];

上面的appkey内容就是业务所需要的信息。

 生成方式(以PHP展示):

$jwt_payload = base64_encode(json_encode($payload,JSON_UNESCAPED_UNICODE));

3、signature(签证)

UND6YbUggeLGx3vLiB3uL2Fm-HKPjedRbvfdaeJRNF0
  • base64之后的header
  • base64之后的payload
  • secret(私有密钥)

签证是需要用到前2步header和payload的base64之后的内容(非原始数据),所以要做签证之前需要把前2步处理好,最后还需要一个私有密钥secret,私有密钥的制定方式由自己控制,可简单的字符串,也可以md5后的内容。

*(私有密钥一定不能暴露在客户端,只能存放于服务器中,由于前2步都可以通过base64 decode进行解析,也就是相当于明文状态,一旦密钥泄露,别人就能随意伪造JWT,切记)

生成方式(以PHP展示):

// 加密方式数组
$alg_config = array(
    'HS256'=>'sha256'
);

// 将base64的header与base64的payload形成字符串
$input = $jwt_header . '.' . $jwt_payload;

// 进行签名加密
$signature = base64_encode(hash_hmac($alg_config[$alg], $input, $secret,true));

 到此JWT所需要的header、payload、signature都已经准备好了,只需要将以上内容按照约定组合起来即可:JWT = header.payload.signature

普遍的应用方式

1、客户端应用

一般客户端都会把JWT存放在头部信息中的Authorization中,并且习惯在JWT前面加bearer进行标注。

headers: {
    'Authorization': 'Bearer ' + jwt
}

 2、签发以及验证流程

顺带说下jwt客户端存储的问题,web应用建议不要使用cookie,使用浏览器自带的本地存储storage。避免对cookie的依赖,由于部分用户会使用无痕或者禁用cookie导致jwt存储失败。

先上完整的JWT-class代码(仅供参考)

jwt.php

<?php
/**
* JWT加密算法
*/
class JwtUtil{
	//头部
    private static $header=array(
        'alg'=>'HS256', //生成signature的算法
        'typ'=>'JWT'    //类型
    );

    //使用HMAC生成信息摘要时所使用的密钥
    private static $key='';


    /**
     * 获取jwt token
     * @param array $payload jwt载荷   格式如下非必须
     * [
     *  'iss'=>'jwt_admin',  //该JWT的签发者
     *  'iat'=>time(),  //签发时间
     *  'exp'=>time()+7200,  //过期时间
     *  'nbf'=>time()+60,  //该时间之前不接收处理该Token
     *  'sub'=>'www.admin.com',  //面向的用户
     *  'jti'=>md5(uniqid('JWT').time())  //该Token唯一标识
     * ]
     * @return bool|string
     */
    public static function encode($param,$jwt_secret)
    {
    	self::$key = $jwt_secret;
        $payload = [
            "iss"	=> ( isset($param['iss'])&&!empty($param['iss']) ) ? $param['iss'] : "",  //签发者 可以为空
            "aud"	=>isset($param['aud'])?$param['aud']:"localhost;192.168.1.108;gzdx.tpddns.cn;liyun.gzdaoxun.com;", //面象的用户,可以为空
            "iat" 	=> ( isset($param['iat'])&&!empty($param['iat']) ) ? $param['iat'] : time(), //签发时间
            "nbf" 	=> ( isset($param['nbf'])&&!empty($param['nbf']) ) ? $param['nbf'] : time(), //在什么时候jwt开始生效 
            "exp" 	=> ( isset($param['exp'])&&!empty($param['exp']) ) ? $param['exp'] : time()+3600*24, //token 过期时间
            "jti" 	=> ( isset($param['jti'])&&!empty($param['jti']) ) ? $param['jti'] : md5(uniqid('JWT').time()),
            "appkey" => ( isset($param['appkey'])&&!empty($param['appkey']) )?$param['appkey']:'', // 签发者密钥
        ];

        if(is_array($payload))
        {
            $base64header=self::base64UrlEncode(json_encode(self::$header,JSON_UNESCAPED_UNICODE));
            $base64payload=self::base64UrlEncode(json_encode($payload,JSON_UNESCAPED_UNICODE));
            $token=$base64header.'.'.$base64payload.'.'.self::signature($base64header.'.'.$base64payload,self::$key,self::$header['alg']);
            return $token;
        }else{
            return false;
        }
    }


    /**
     * 验证token是否有效,默认验证exp,nbf,iat时间
     * @param string $Token 需要验证的token
     * @return bool|string
     */
    public static function decode($Token,$jwt_secret,$debug=false)
    {
    	self::$key = $jwt_secret;
        $tokens = explode('.', $Token);
        if (count($tokens) != 3){
            if ($debug) 
                var_dump(1);
            return false;
        }
            
        list($base64header, $base64payload, $sign) = $tokens;
        //获取jwt算法
        $base64decodeheader = json_decode(self::base64UrlDecode($base64header), JSON_OBJECT_AS_ARRAY);
        if (empty($base64decodeheader['alg'])){
            if ($debug) 
                var_dump(2);
            return false;
        }
            
        //签名验证
        $de_sign = self::signature($base64header . '.' . $base64payload, self::$key, $base64decodeheader['alg']);
        if ($de_sign != $sign){
            if ($debug) 
                var_dump(3);
            return false;
        }
            
        $payload = json_decode(self::base64UrlDecode($base64payload), JSON_OBJECT_AS_ARRAY);

        //签发时间大于当前服务器时间验证失败
        if (isset($payload['iat']) && $payload['iat'] > time())
        {
            if ($debug) 
                var_dump(4);
            return false;
        }
            
        //过期时间小宇当前服务器时间验证失败
        if (isset($payload['exp']) && $payload['exp'] < time())
        {
            if ($debug) 
                var_dump(5);
            return false;
        };

        //该nbf时间之前不接收处理该Token
        if (isset($payload['nbf']) && $payload['nbf'] > time())
        {
            if ($debug) 
                var_dump(6);
            return false;
        }

        return $payload;
    }




    /**
     * base64UrlEncode   https://jwt.io/  中base64UrlEncode编码实现
     * @param string $input 需要编码的字符串
     * @return string
     */
    private static function base64UrlEncode($input)
    {
        return str_replace('=', '', strtr(base64_encode($input), '+/', '-_'));
    }

    /**
     * base64UrlEncode  https://jwt.io/  中base64UrlEncode解码实现
     * @param string $input 需要解码的字符串
     * @return bool|string
     */
    private static function base64UrlDecode($input)
    {
        $remainder = strlen($input) % 4;
        if ($remainder) {
            $addlen = 4 - $remainder;
            $input .= str_repeat('=', $addlen);
        }
        return base64_decode(strtr($input, '-_', '+/'));
    }

    /**
     * HMACSHA256签名   https://jwt.io/  中HMACSHA256签名实现
     * @param string $input 为base64UrlEncode(header).".".base64UrlEncode(payload)
     * @param string $key
     * @param string $alg   算法方式
     * @return mixed
     */
    private static function signature($input, $key, $alg = 'HS256')
    {
        $alg_config=array(
            'HS256'=>'sha256'
        );
        return self::base64UrlEncode(hash_hmac($alg_config[$alg], $input, $key,true));
    }
}

 然后总结下

 先对上部分内容总结,下面还会加多一部分jwt实际应用之接口双重认证。

JWT优点:

  • 支持跨域访问:而JWT Token由于没有用到cookie,本地存储头部传递参数,所以就不存在跨域问题
  • 无状态:JWT Token机制在服务端不需要存储session信息,因为JWT Token自身包含了所有登录用户的信息,所以可以减轻服务端压力
  • 更适用多端应用:当客户端是非B/S架构时,cookie是不被支持的,此时采用JWT认证方式会简单很多
  • 无需考虑CSRF:由于不再依赖cookie,所以采用token认证方式不会发生CSRF,所以也就无需考虑CSRF的防御,当然采用HTTPS模式就最好

其他内容:

  • session是空间换时间,token是时间换空间
  • 在不同应用场景下,session有自身的优势与价值,存在即合理
  • Base64是编码,RSA/AES是加密,MD5是取摘要
  • jwt在线decode工具官网首页就有

全部JWT签名算法:

  • HS256:HMAC SHA256(常用)
  • HS384:HMAC SHA384
  • HS512:HMAC SHA512
  • RS256:RSA SHA256
  • RS384:RSA SHA384
  • RS512:RSA SHA512
  • ES256:ECDSA SHA256
  • ES384:ECDSA SHA384
  • ES512:ECDSA SHA512
  • PS256:PSA SHA256
  • PS384:PSA SHA384
  • PS512:PSA SHA512

关于加密算法这块就不在这里赘述了,想了解全部内容的朋友可以按照这个表去逐一学习下。就在这里大概总结下对于单体应用来说HS256和RS256的安全性没多大区别,需要进行多方验证的微服务架构而言, RS256/ES256 安全性更高。

JWT实际应用

【前提】

在日常开发中我们会经常遇到多个系统需要进行消息互通的场景,一般消息互通都是采用API接口数据传输模式,这就需要我们对接口安全性负责。真实情况下会有各种各样的安全模式,但是这里就不讨论哪个最好,这里主要是将JWT更加深度应用起来(最简单的应用就是客户端+服务端的应用),JWT对于各个层级的开发者来说都相对比较容易理解和上手。

假设现在就有SCRM系统与ERP系统接口对接的业务。

【JWT应用介绍】

JWT采用双重认证模式,第一重认证访问接口用户有效性,并提取出appkey;第二重认证接口数据有效性。

appkey由header的Authorization中提取,无需通过接口post传输。

【加密算法】

第一重:Authorization由JWT模式生成,详情请看上文内容。

第二重:sign由MD5模式生成,具体公式:md5(appkey(jwt提取) + method(接口名) + timestamp(时间戳) + data(业务字段数组的json) + secret(接口密钥))生成,顺序可自由调整。

【data】

data主要是对象数组,里面函数接口需要的全部业务字段,先采用sort升序排序,再用Json Encode(保留中文)

原始data:

$data = [
	"vip_num" => "v0001",
    "vip_name" => "XX测试账号"
];

排序后data:

$data = [
    "vip_name" => "XX测试账号",
	"vip_num" => "v0001"
];

Json Encode后的data:

$data = {"vip_name" : "XX测试账号","vip_num" : "v0001"}

【secret】

  • appkey(钥匙):数据验证中需要用到的密钥
  • jwt_secret(jwt的令牌):主要作用于JWT的生成和解析中,与sign-secret不一样,只能保存在内部服务器中
  • sign_secret(sign的令牌):主要作用于sign的生成中无法解析,与jwt-secret不一样,只能保存在内部服务器中

 appkey、jwt_secret、sign_secret的生成只要保证唯一性即可,业务上可以使用uuid或者结合业务内容组合,然后可以采用md5截取哈希值,再使用base64进行编码处理等操作。由于交互的双方都是服务器直接互通,所以令牌有一定安全性,只要服务器不被攻破,其次是双重认证,令牌存放不同位置,只丢失其中一个令牌不会对整体造成影响。

;