JWT

JWT(JSON Web Token), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

JWT的组成

  • Header(头部) —— base64编码的Json字符串
  • Payload(载荷) —— base64编码的Json字符串
  • Signature(签名)—— 使用指定算法,通过Header和Payload加盐计算的字符串

header

jwt的头部承载两部分信息:

{
  'typ': 'JWT', //声明类型
  'alg': 'RS256' //签名加密的算法
}

然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分.

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

playload

载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分:

  • 标准中注册的声明 (==建议但不强制使用==) :
{ "iss": "JWT Builder", //jwt签发者
  "iat": 1416797419, // jwt的签发时间
  "exp": 1448333419,  //jwt的过期时间,这个过期时间必须要大于签发时间
  "aud": "www.bilibili.com", //接收jwt的一方
  "sub": "[email protected]",  //jwt所面向的用户
  "GivenName": "Levin",
  "Surname": "Levin",
  "Email": "[email protected]",
  "Role": [ "ADMIN", "MEMBER" ],
  "nbf" : 1416797420 //定义在什么时间之前,该jwt都是不可用的,
  "jti" : "jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击"
}
  • ==公共==的声明 :
    公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.
  • ==私有==的声明 :
    私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

定义一个payload:

// 包括需要传递的用户信息;
{ "iss": "Online JWT Builder",
  "iat": 1416797419,
  "exp": 1448333419,
  "aud": "www.gusibi.com",
  "sub": "uid",
  "nickname": "goodspeed",
  "username": "goodspeed",
  "scopes": [ "admin", "user" ]
}

然后将其进行base64加密,得到Jwt的第二部分。

eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE0MTY3OTc0MTksImV4cCI6MTQ0ODMzMzQxOSwiYXVk

signature

jwt的第三部分是一个签证信息,这个签证信息由三部分组成:

// 根据头部alg算法与私有秘钥进行加密得到的签名字符串;
// 这一段是最重要的敏感信息,只能在服务端解密;
HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    SECREATE_KEY
)

这个部分需要base64加密后的header和base64加密后的payload使用 "." 连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。

// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

将这三部分用"."连接成一个完整的字符串,构成了最终的jwt:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

加密及验证过程

加密:

生成头JSON,荷载(playload) JSON

将头JSON Base64编码 + 荷载JSON Base64编码 +secret 三者拼接进行加密得到签名

JSON Base64编码 + 荷载JSON Base64编码 + 签名 三者通过 "." 相连接

一条 hhh.ppp.sss 格式的JWT 即生成

解密:

取得Jwt hhh.ppp.sss 格式字符,通过 "." 将字符分为三段

对第一段进行Base64解析得到header json,获取加密算法类型

将第一段Header JSON Base64编码 + 第二段 荷载JSON Base64编码 + secret采用相应的加密算法加密得到签名

将步骤三得到的签名与步骤一分成的第三段也就是客户端传入的签名进行匹配,匹配成功说明该jwt为server自身产出;

获取playload内信息,通过信息可以做鉴权操作;

成功访问;

通过这些步骤,保证了第三方无法修改jwt,jwt只能自产自销,在分布式环境下服务接收到合法的jwt便可知是本系统内自身或其他服务发出的jwt,该用户是合法的;

X509

X.509是常见通用的证书格式。所有的证书都符合为Public Key Infrastructure (PKI) 制定的 ITU-T X509 国际标准。X.509是国际电信联盟-电信(ITU-T)部分标准和国际标准化组织(ISO)的证书格式标准。作为ITU-ISO目录服务系列标准的一部分,X.509是定义了公钥证书结构的基本标准。1988年首次发布,1993年和1996年两次修订。当前使用的版本是X.509 V3,它加入了扩展字段支持,这极大地增进了证书的灵活性。X.509 V3证书包括一组按预定义顺序排列的强制字段,还有可选扩展字段,即使在强制字段中,X.509证书也允许很大的灵活性,因为它为大多数字段提供了多种编码方案.

JWT 最常见的几种签名算法HS256(HMAC-SHA256) 、RS256(RSA-SHA256) 还有 ES256(ECDSA-SHA256)。

这三种算法都是一种消息签名算法,得到的都只是一段无法还原的签名。区别在于消息签名签名验证需要的 「key」不同。

  1. HS256 使用同一个「secret_key」进行签名与验证。一旦 secret_key 泄漏,就毫无安全性可言了。

    • 因此 HS256 只适合集中式认证,签名和验证都必须由可信方进行。
  2. RS256 是使用 RSA 私钥进行签名,使用 RSA 公钥进行验证。公钥即使泄漏也毫无影响,只要确保私钥安全就行。

    • RS256 可以将验证委托给其他应用,只要将公钥给他们就行。
  3. ES256 和 RS256 一样,都使用私钥签名,公钥验证。算法速度上差距也不大,但是它的签名长度相对短很多(省流量),并且算法强度和 RS256 差不多。

对于单体应用而言,HS256 和 RS256 的安全性没有多大差别。
而对于需要进行多方验证的微服务架构而言,显然 RS256/ES256 安全性更高。
只有 user 微服务需要用 RSA 私钥生成 JWT,其他微服务使用公钥即可进行签名验证,私钥得到了更好的保护。

无状态登录

微服务集群中的每个服务, 对外提供的都是Rest风格的接口, 而Rest风格的一个最重要的规范就是: 服务的无状态性, 即:

  • 服务端不保存任何客户端请求者状态信息
  • 客户端的每次请求必须具备自描述信息, 通过这些信息识别客户端身份

优点:

  • 客户端请求不依赖服务端的信息, 任何多次请求不需要必须访问到同一台服务
  • 服务端的集群和状态对客户端透明
  • 服务端可以任意的迁移和伸缩
  • 减小服务端存储压力

JJWT

jjwt是一个Java对jwt的支持库,我们使用这个库来创建、解码token

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>

配合joda-time处理过期时间

 <dependency>
     <groupId>joda-time</groupId>
     <artifactId>joda-time</artifactId>
     <version>2.9.6</version>
 </dependency>

生成JWT

客户端发送 POST 请求到服务器,提交登录处理的Controller层
调用认证服务进行用户名密码认证,如果认证通过,返回完整的用户信息及对应权限信息
利用 JJWT 对用户、权限信息、秘钥构建Token
返回构建好的Token
下面是关键代码, 文章后面有全部的工具类

    /**
     * 私钥加密生成token
     * @param user 载荷数据
     * @param privateKey 私钥字节数组
     * @param expireMinutes 过期时间,单位分钟
     * @return
     */
    public static String generateToken(ShopUser user, byte[] privateKey, Integer expireMinutes) throws Exception{

        return Jwts.builder()
                .claim(JWTConstants.JWT_KEY_ID, user.getId())
                .claim(JWTConstants.JWT_KEY_USER_NAME, user.getUserName())
                .claim(JWTConstants.JWT_KEY_ROLE, user.getRole())
                .setExpiration(DateTime.now().plusMinutes(expireMinutes).toDate())
                .signWith(SignatureAlgorithm.RS256, RsaUtils.getPrivateKey(privateKey))
                .compact();
    }

Jwts.builder() 返回了一个 DefaultJwtBuilder()

DefaultJwtBuilder属性

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    private Header header; //头部
    private Claims claims; //声明
    private String payload; //载荷
    private SignatureAlgorithm algorithm; //签名算法
    private Key key; //签名key
    private byte[] keyBytes; //签名key的字节数组
    private CompressionCodec compressionCodec; //压缩算法

DefaultJwtBuilder包含了一些Header和Payload的一些常用设置方法

解析&验证JWT

使用私钥加密的jwt, 公钥和私钥都可以解密
使用公钥加密的jwt, 只有私钥可以解密
客户端向服务器请求,服务端读取请求头信息(request.header)获取Token
如果找到Token信息,则根据配置文件中的签名加密秘钥,调用JJWT Lib对Token信息进行解密和解码;
完成解码并验证签名通过后,对Token中的exp、nbf、aud等信息进行验证;
全部通过后,根据获取的用户的角色权限信息,进行对请求的资源的权限逻辑判断;
如果权限逻辑判断通过则通过Response对象返回;否则则返回HTTP 401;

/**
 * 公钥解析token
 * @param token 用户请求中的token
 * @param publicKey 公钥字节数组
 * @return
 * @throws Exception
 */
private static Jws<Claims> parserToken(String token, byte[] publicKey) throws Exception {
    return Jwts.parser().setSigningKey(RsaUtils.getPublicKey(publicKey))
            .parseClaimsJws(token);
}

Jwts.parser() 返回了DefaultJwtParser 对象

DefaultJwtParser() 属性

//don't need millis since JWT date fields are only second granularity:
private static final String ISO_8601_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'";
private static final int MILLISECONDS_PER_SECOND = 1000;

private ObjectMapper objectMapper = new ObjectMapper();

private byte[] keyBytes; //签名key字节数组
private Key key; //签名key
private SigningKeyResolver signingKeyResolver; //签名Key解析器
private CompressionCodecResolver compressionCodecResolver = new DefaultCompressionCodecResolver(); //压缩解析器
Claims expectedClaims = new DefaultClaims(); //期望Claims
private Clock clock = DefaultClock.INSTANCE; //时间工具实例
private long allowedClockSkewMillis = 0;  //允许的时间偏移量

parse() 方法传入一个JWT字符串,返回一个JWT对象

解析过程

  1. 检查: 以分隔符" . "切分JWT的三个部分。如果分隔符数量错误或者载荷为空,将抛出 MalformedJwtException 异常。
  2. 头部解析: 将头部原始Json键值存入map。根据是否加密创建不同的头部对象。jjwt的DefaultCompressionCodecResolver根据头部信息的压缩算法信息,添加不同的压缩解码器。
  3. 载荷解析: 先对载荷进行Base64解码,如果有经过压缩,那么在解码后再进行解压缩。此时将值赋予payload。如果载荷是json形式,将json键值读入map,将值赋予claims 。

    if (payload.charAt(0) == '{' && payload.charAt(payload.length() - 1) == '}') {
        //likely to be json, parse it:
        Map<String, Object> claimsMap = readValue(payload);
        claims = new DefaultClaims(claimsMap);
    }
  4. 签名解析: 如果存在签名部分,则对签名进行解析。

    • 首先根据头部的签名算法信息,获取对应的算法。
      如果签名部分不为空,但是签名算法为null或者'none',将抛出MalformedJwtException异常。
    • 获取签名key
    • 可能的异常

      • 如果同时设置了key属性和keyBytes属性,parser不知道该使用哪个值去作为签名key解析,将抛出异常。
      • 如果key属性和keyBytes属性只存在一个,但是设置了signingKeyResolver,也不知道该去解析前者还是使用后者,将抛出异常。
      • 如果设置了key(setSigningKey() 方法)则直接使用生成Key对象。如果两种形式( key和keyBytes )都没有设置,则使用SigningKeyResolver(通过setSigningKeyResolver()方法设置)获取key, 当然,获取key为null会抛出异常
    • 创建签名校验器
      JJWT实现了一个默认的签名校验器DefaultJwtSignatureValidator。该类提供了两个构造方法,外部调用的构造方法传入算法和签名key,再加上一个DefaultSignatureValidatorFactory工厂实例传递调用另一个构造函数,以便工厂根据不同算法创建不同类型的Validator。

      public DefaultJwtSignatureValidator(SignatureAlgorithm alg, Key key) {
          this(DefaultSignatureValidatorFactory.INSTANCE, alg, key);
      }
      
      public DefaultJwtSignatureValidator(SignatureValidatorFactory factory, SignatureAlgorithm alg, Key key) {
          Assert.notNull(factory, "SignerFactory argument cannot be null.");
          this.signatureValidator = factory.createSignatureValidator(alg, key);
      }
    • 比对验证
      根据头部和载荷重新计算签名并比对。
      如果不匹配,抛出SignatureException异常
    • 时间校验
      根据当前时间和时间偏移判断是否过期。
      根据当前时间和时间偏移判断是够未到可接收时间
    • Claims参数校验
      即校验parser前面设置的所以require部分。校验完成后,以header,claims或者payload创建DefaultJwt对象返回
    • 至此,已经完成JWT Token的校验过程。校验通过后返回JWT对象。

工具类

JWTUtils

import com.uni.entity.ShopUser;
import io.jsonwebtoken.*;
import lombok.extern.slf4j.Slf4j;
import org.joda.time.*;
import java.security.PrivateKey;
import java.security.PublicKey;


/**
 *  JWT 的工具类:包含了创建和解码的工具
 */
@Slf4j
public class JWTUtils {

    /**
     * 私钥加密token
     * @param user 载荷数据
     * @param privateKey 私钥
     * @param expireMinutes 过期时间,单位分钟
     * @return
     */
    public static String generateToken(ShopUser user, PrivateKey privateKey, Integer expireMinutes) throws Exception{

        return Jwts.builder()
                .claim(JWTConstants.JWT_KEY_ID, user.getId())
                .claim(JWTConstants.JWT_KEY_USER_NAME, user.getUserName())
                .claim(JWTConstants.JWT_KEY_ROLE, user.getRole())
                .setExpiration(DateTime.now().plusMinutes(expireMinutes).toDate())
                .signWith(SignatureAlgorithm.RS256, privateKey)
                .compact();
    }

    /**
     * 私钥加密token
     * @param user 载荷数据
     * @param privateKey 私钥字节数组
     * @param expireMinutes 过期时间,单位分钟
     * @return
     */
    public static String generateToken(ShopUser user, byte[] privateKey, Integer expireMinutes) throws Exception{

        return Jwts.builder()
                .claim(JWTConstants.JWT_KEY_ID, user.getId())
                .claim(JWTConstants.JWT_KEY_USER_NAME, user.getUserName())
                .claim(JWTConstants.JWT_KEY_ROLE, user.getRole())
                .setExpiration(DateTime.now().plusMinutes(expireMinutes).toDate())
                .signWith(SignatureAlgorithm.RS256, RsaUtils.getPrivateKey(privateKey))
                .compact();
    }

    /**
     * 使用公钥解析token
     * @param token 用户请求中的token
     * @param publicKey 公钥对象
     * @return
     */
    public static Jws<Claims> parserToken(String token, PublicKey publicKey){
        return Jwts.parser().setSigningKey(publicKey).parseClaimsJws(token);
    }

    /**
     * 公钥解析token
     * @param token 用户请求中的token
     * @param publicKey 公钥字节数组
     * @return
     * @throws Exception
     */
    private static Jws<Claims> parserToken(String token, byte[] publicKey) throws Exception {
        return Jwts.parser().setSigningKey(RsaUtils.getPublicKey(publicKey))
                .parseClaimsJws(token);
    }

    /**
     * 获取token中的用户信息
     * @param token 用户请求中的令牌
     * @param publicKey 公钥
     * @return 用户信息
     * @throws Exception
     */
    public static ShopUser getInfoFromToken(String token, PublicKey publicKey) throws Exception {
        Jws<Claims> claimsJws = parserToken(token, publicKey);
        Claims body = claimsJws.getBody();

        Long user_id = (Long) body.get(JWTConstants.JWT_KEY_ID);
        String user_name = (String) body.get(JWTConstants.JWT_KEY_USER_NAME);
        Integer user_role = (Integer) body.get(JWTConstants.JWT_KEY_ROLE);
        return new ShopUser(user_id, user_name, user_role);
    }

    /**
     * 获取token中的用户信息
     * @param token 用户请求中的token
     * @param publicKey 公钥字节数组
     * @return 用户信息
     * @throws Exception
     */
    public static ShopUser getInfoFromToken(String token, byte[] publicKey) throws Exception {
        Jws<Claims> claimsJws = parserToken(token, publicKey);
        Claims body = claimsJws.getBody();

        Long user_id = (Long) body.get(JWTConstants.JWT_KEY_ID);
        String user_name = (String) body.get(JWTConstants.JWT_KEY_USER_NAME);
        Integer user_role = (Integer) body.get(JWTConstants.JWT_KEY_ROLE);
        return new ShopUser(user_id, user_name, user_role);
    }

    /* 测试解析token */
    public static void main(String[] args) throws Exception {
        PublicKey publicKey = RsaUtils.getPublicKey("D://rsa//rsa.pub");
        Jws<Claims> claimsJws = parserToken("eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyX2lkIjoxMjczOTEyMTE1MDI3MTE2MDMyLCJ1c2VyX3JvbGUiOjAsImV4cCI6MTU5MzMxODM2OH0.FqXgDP6b3qoTrAXteCHxQ2IUnryh_7XfeUHPTW8bXiLpXVDn1zigBJTGcxFhivcy0aIACBs32i0ynbBc5DUli6chesvIE7HfbAl9IiBj0D6Ujde-HnQdHcrzjPt783fy-5Voj4HJZWHrAH9SCPkKqs6VUUR6Ba8QHJeoJtkmUXg", publicKey);
        System.out.println(claimsJws.getSignature());
        System.out.println(claimsJws.toString());
    }

}

RsaUtils

import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.impl.crypto.DefaultJwtSignatureValidator;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;

/**
 * rsa非对称加密
 * 私钥加密,解密需要公钥
 */
public class RsaUtils {

    /**
     * 从文件中读取公钥
     * @param filename 公钥保存路径,相对于classpath
     * @return 公钥对象
     * @throws Exception
     */
    public static PublicKey getPublicKey(String filename) throws Exception {
        byte[] bytes = readFile(filename);
        return getPublicKey(bytes);
    }

    /**
     *  获取公钥
     * X.509是定义了公钥证书结构的基本标准
     * @param bytes 公钥的字节形式
     * @return 公钥对象
     * @throws Exception
     */
    public static PublicKey getPublicKey(byte[] bytes) throws Exception {
        X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
        KeyFactory factory = KeyFactory.getInstance("RSA");
        return factory.generatePublic(spec);
    }

    /**
     * 从文件中读取私钥
     * @param filename 私钥保存路径,相对于classpath
     * @return 私钥对象
     * @throws Exception
     */
    public static PrivateKey getPrivateKey(String filename) throws Exception {
        byte[] bytes = readFile(filename);
        return getPrivateKey(bytes);
    }

    /**
     * 获取私钥
     * @param bytes 私钥的字节形式
     * @return 私钥对象
     * @throws Exception
     */
    public static PrivateKey getPrivateKey(byte[] bytes) throws Exception {
        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
        KeyFactory factory = KeyFactory.getInstance("RSA");
        return factory.generatePrivate(spec);
    }

    /**
     * 根据密文,生成rsa公钥和私钥,并写入指定文件
     * @param publicKeyFilename 公钥文件路径
     * @param privateKeyFilename 私钥文件路径
     * @param secret 生成密钥的密文
     * @throws Exception
     */
    public static void generateKey(String publicKeyFilename,
                                   String privateKeyFilename, String secret) throws Exception {

        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        SecureRandom secureRandom = new SecureRandom(secret.getBytes());
        keyPairGenerator.initialize(1024, secureRandom);
        KeyPair keyPair = keyPairGenerator.genKeyPair();
        //获取公钥并写出
        byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
        writeFile(publicKeyFilename, publicKeyBytes);
        //获取私钥并写出
        byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
        writeFile(privateKeyFilename, privateKeyBytes);
    }

    private static byte[] readFile(String filename) throws Exception {
        return Files.readAllBytes(new File(filename).toPath());
    }

    private static void writeFile(String destPath, byte[] bytes) throws IOException{
        File dest = new File(destPath);
        if (!dest.exists()){
            dest.createNewFile();
        }
        Files.write(dest.toPath(), bytes);
    }

    /* 测试公私钥获取 */
    public static void main(String[] args) throws Exception {
        //公私钥路径
        String pubKeyPath = "D:\\rsa\\rsa.pub";
        String priKeyPath = "D:\\rsa\\rsa.pri";

        //明文
        String secret = "sc@Login(Auth}*^31)&czxy%";
        //RsaUtils.generateKey(pubKeyPath, priKeyPath, secret);

        /* 解密 */
        PublicKey publicKey = RsaUtils.getPublicKey(pubKeyPath);
        System.out.println("公钥: " + publicKey);
        PrivateKey privateKey = RsaUtils.getPrivateKey(priKeyPath);
        System.out.println("私钥: " + privateKey);
        //签名验证器
        DefaultJwtSignatureValidator validator = new DefaultJwtSignatureValidator(SignatureAlgorithm.RS256, publicKey);
        boolean valid = validator.isValid("eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyX2lkIjoxMjczOTEyMTE1MDI3MTE2MDMyLCJ1c2VyX3JvbGUiOjAsImV4cCI6MTU5MzMxODM2OH0", "FqXgDP6b3qoTrAXteCHxQ2IUnryh_7XfeUHPTW8bXiLpXVDn1zigBJTGcxFhivcy0aIACBs32i0ynbBc5DUli6chesvIE7HfbAl9IiBj0D6Ujde-HnQdHcrzjPt783fy-5Voj4HJZWHrAH9SCPkKqs6VUUR6Ba8QHJeoJtkmUXg");
        System.out.println(valid);
    }
}

JWTProperties

package com.uni.config;

import com.uni.util.RsaUtils;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;

import javax.annotation.PostConstruct;
import java.io.File;
import java.security.PrivateKey;
import java.security.PublicKey;

/**
 * 初始化公钥和私钥
 */
@Slf4j
@Data
@PropertySource("classpath:application.yml")
@ConfigurationProperties(prefix = "jwt")
@Configuration
public class JWTProperties {

    private String secret; // 密文

    private String pubKeyPath;// 公钥

    private String priKeyPath;// 私钥

    private Integer expire;// token过期时间

    private String[] skipAuthUrls; //跳过的url

    private PublicKey publicKey; // 公钥

    private PrivateKey privateKey; // 私钥

    //被@PostConstruct修饰的方法会在服务器加载Servlet的时候运行,并且只会被服务器调用一次
    @PostConstruct
    public void init() {
        try {
            log.info("公钥地址: " + pubKeyPath);
            log.info("私钥地址: " + priKeyPath);
            File pubKey = new File(pubKeyPath);
            File priKey = new File(priKeyPath);

            if (!pubKey.exists() || !priKey.exists()) {
                // 生成公钥和私钥并写入文件
                RsaUtils.generateKey(pubKeyPath, priKeyPath, secret);
            }
            // 获取公钥和私钥
            this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
            this.privateKey = RsaUtils.getPrivateKey(priKeyPath);
        } catch (Exception e) {
            log.error("初始化公钥和私钥失败! " + e);
            throw new RuntimeException();
        }
    }
}

配置如下:

jwt:
  secret: sc@Login(Auth}*^31)&czxy% # 登录校验的明文
  pubKeyPath: D://rsa//rsa.pub # 公钥地址
  priKeyPath: D://rsa//rsa.pri # 私 钥地址
  expire: 30 # 过期时间,单位分钟
  skipAuthUrls:
    - /auth/**
    - ...

JWTConstants

public class JWTConstants {

    public static final String JWT_HEADER_KEY = "Authorization";

    public static final String JWT_KEY_ID = "user_id";

    public static final String JWT_KEY_USER_NAME = "user_name";

    public static final String JWT_KEY_ROLE = "user_role";
}

JWTModel

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class JWTModel {

    private Long userId;

    private String userName;

    private String jwt;
}

用户登录


import com.uni.config.JWTProperties;
import com.uni.entity.Dto;
import com.uni.entity.ShopUser;
import com.uni.service.ShopUserService;
import com.uni.util.*;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;


@Slf4j
@RestController
@RequestMapping("/auth")
public class AuthAPI {

    @Autowired
    private ShopUserService shopUserService;

    @Autowired
    private JWTProperties jwtProperties;

    @PostMapping("/login")
    public Dto doLogin(@RequestBody ShopUser user){
        ShopUser result = null;
        // 验证用户明和密码
        if (ObjectUtils.isNotEmpty(user)) {
             result = shopUserService.login(user);
        }
        if (ObjectUtils.isEmpty(result)){
            return DtoUtil.returnFail("用户名或密码错误", "401");
        }
        try {
            //生成token
            String token = JWTUtils.generateToken(
                    result, jwtProperties.getPrivateKey(), 30);
            return DtoUtil.returnSuccess("登录成功",
                    new JWTModel(result.getId(), result.getUserName(), token));
        } catch (Exception e) {
            log.error("生成token失败! ", e);
            return DtoUtil.returnFail("登录失败", "500");
        }
    }
}

网关鉴权

import com.uni.config.JWTProperties;
import com.uni.util.JWTConstants;
import com.uni.util.JWTUtils;
import com.uni.util.RsaUtils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.SignatureAlgorithm;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.joda.time.Minutes;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.context.annotation.PropertySource;
import org.springframework.core.Ordered;

/**
 * 请求鉴权过滤器
 */
@Slf4j
@Component
public class AccessGateWayFilter implements GlobalFilter, Ordered {

    private ObjectMapper objectMapper;

    @Autowired
    private JWTProperties jwtProperties;

    @Autowired
    private AntPathMatcher antPathMatcher; //路径匹配器

    public AccessGateWayFilter(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String url = exchange.getRequest().getURI().getPath();

        //跳过不需要验证的url
        for (String skip : jwtProperties.getSkipAuthUrls()) {
            if (antPathMatcher.match(skip, url))
                return chain.filter(exchange);
        }

        //获取token
        String token = exchange.getRequest().getHeaders().getFirst(JWTConstants.JWT_HEADER_KEY);
        ServerHttpResponse response = exchange.getResponse();

        if (StringUtils.isBlank(token)){
            //没有token
            return authError(response, "请登录");
        } else {
            try {
                //解析token
                Jws<Claims> claims = JWTUtils.parserToken(token, jwtProperties.getPublicKey());
                DateTime now = DateTime.now();
                DateTime exp = new DateTime(claims.getBody().getExpiration());

                log.debug(claims.getBody().getExpiration().toString());

                /*
                    根据具体业务
                    用户信息&权限验证
                */
                //claims.getBody()获取载荷
                //JWTUtils.getInfoFromToken()获取token中的用户信息

                if (valid){ //签名验证通过
                    return chain.filter(exchange);
                }else {
                    return authError(response, "认证无效");
                }
            } catch (Exception e) {
                log.error("检查token时异常: " + e);
                if (e.getMessage().contains("JWT expired"))
                    return authError(response, "认证过期");
                else
                    return authError(response, "认证失败");
            }
        }
    }

    /**
     * 认证错误输出
     * @param response 响应对象
     * @param msg 错误信息
     * @return 响应信息
     */
    private Mono<Void> authError(ServerHttpResponse response, String msg) {
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().add("Content-Type","application/json;charset=UTF-8");
        Dto returnFail = DtoUtil.returnFail(msg, HttpStatus.UNAUTHORIZED.toString());
        String returnStr = "";
        try {
            returnStr = objectMapper.writeValueAsString(returnFail);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        DataBuffer buffer = response.bufferFactory().wrap(returnStr.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Flux.just(buffer));
    }


    @Override
    public int getOrder() {
        return -999;
    }
}

刷新JWT

令牌的刷新要做到用户无感知的效果, 推荐使用前端拦截器刷新令牌的方式

Web应用程序

一个好的模式是在它过期之前刷新令牌。

将令牌过期时间设置为一周,并在每次用户打开Web应用程序并每隔一小时刷新令牌。如果用户超过一周没有打开过应用程序,那他们就需要再次登录,这是可接受的Web应用程序UX(用户体验)。

要刷新令牌,API需要一个新的端点,它接收一个有效的、没有过期的JWT、并返回与新的到期字段相同的签名的JWT。然后Web应用程序会将令牌存储在某处。

移动/本地应用程序

大多数本地应用程序的登录有且仅有一次。

这里面的出发点是,刷新令牌永远不会过期,并且可以始终为有效的JWT进行更换。

永远不会过期的令牌的问题是它失去了令牌的意义。譬如,如果你电话丢了,你该怎么办?因此,它需要由用户以某种方式进行识别,应用程序需要提供撤销访问的方法。我们决定使用设备的名称,例如“maryo的iPad”。然后用户可以去应用程序,并撤销访问“maryo的iPad”。

另一种方法是撤销特定事件的刷新令牌,其中一个有趣的事件是更改密码。

我们认为JWT对于这些用例无效,因此我们使用随机生成的字符串,并将它们存储在我们这边。

注销

没有办法完美的将jwt失效

jwt 的目的本来就是为了在服务器不存任何的东西, 用加解密 的 cpu 时间来换取以前要保存的空间 , 说白了就是用 cpu 时间换内存空间(这个内存可以是 session, 也可能是 redis 这种)

可能的解决方案:

  • 将JWT存储在数据库中。您可以检查哪些令牌有效以及哪些令牌已被撤销,但这在我看来完全违背了使用JWT的目的。
  • 从客户端删除令牌。这将阻止客户端进行经过身份验证的请求,但如果令牌仍然有效且其他人可以访问它,则仍可以使用该令牌。这引出了我的下一点。
  • 令牌生命周期短。让令牌快速到期。根据应用,可能是几分钟或半小时。当客户端删除其令牌时,会有一个很短的时间窗口仍然可以使用它。从客户端删除令牌并具有短令牌生存期不需要对后端进行重大修改。但是令牌生命周期短意味着用户因令牌已过期而不断被注销。
  • 旋转代币。也许引入刷新令牌的概念。当用户登录时,为他们提供JWT和刷新令牌。将刷新令牌存储在数据库中。对于经过身份验证的请求,客户端可以使用JWT,但是当令牌过期(或即将过期)时,让客户端使用刷新令牌发出请求以换取新的JWT。这样,您只需在用户登录或要求新的JWT时访问数据库。当用户注销时,您需要使存储的刷新令牌无效。否则,即使用户已经注销,有人在监听连接时仍然可以获得新的JWT。
  • 创建JWT黑名单。根据过期时间,当客户端删除其令牌时,它可能仍然有效一段时间。如果令牌生存期很短,则可能不是问题,但如果您仍希望令牌立即失效,则可以创建令牌黑名单。当后端收到注销请求时,从请求中获取JWT并将其存储在内存数据库中。对于每个经过身份验证的请求,您需要检查内存数据库以查看令牌是否已失效。为了保持较小的搜索空间,您可以从黑名单中删除已经过期的令牌。
03-05 21:28