Spring Security - 实现JWT Token

1. 前言

Json Web TokenJWT ) 近几年是前后端分离常用的 Token 技术,是目前最流行的跨域身份验证解决方案。你可以通过文章 一文了解web无状态会话token技术JWT 来了解 JWT 。今天我们来手写一个通用的 JWT 服务。 DEMO 获取方式在文末,实现在 jwt 相关包下

2. spring-security-jwt

spring-security-jwtSpring Security Crypto 提供的 JWT 工具包 。

 <dependency>
       <groupId>org.springframework.security</groupId>
       <artifactId>spring-security-jwt</artifactId>
       <version>${spring-security-jwt.version}</version>
 </dependency>        

核心类只有一个: org.springframework.security.jwt.JwtHelper 。它提供了两个非常有用的静态方法。

3. JWT 编码

JwtHelper 提供的第一个静态方法就是 encode(CharSequence content, Signer signer) 这个是用来生成jwt的方法 需要指定 payloadsigner 签名算法。 payload 存放了一些可用的 不敏感 信息:

  • iss jwt签发者
  • sub jwt所面向的用户
  • aud 接收jwt的一方
  • iat jwt的签发时间
  • exp jwt的过期时间,这个过期时间必须要大于签发时间 iat
  • jti jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击

除了以上提供的基本信息外,我们可以定义一些我们需要传递的信息,比如目标用户的权限集 等等。 切记不要传递密码等敏感信息 ,因为 JWT 的前两段都是用了 BASE64 编码,几乎算是明文了。

3.1 构建 JWT 中的 payload

我们先来构建 payload :

/**
 * 构建 jwt payload
 *
 * @author Felordcn
 * @since 11:27 2019/10/25
 **/
public class JwtPayloadBuilder {

    private Map<String, String> payload = new HashMap<>();
    /**
     * 附加的属性
     */
    private Map<String, String> additional;
    /**
     * jwt签发者
     **/
    private String iss;
    /**
     * jwt所面向的用户
     **/
    private String sub;
    /**
     * 接收jwt的一方
     **/
    private String aud;
    /**
     * jwt的过期时间,这个过期时间必须要大于签发时间
     **/
    private LocalDateTime exp;
    /**
     * jwt的签发时间
     **/
    private LocalDateTime iat = LocalDateTime.now();
    /**
     * 权限集
     */
    private Set<String> roles = new HashSet<>();
    /**
     * jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击
     **/
    private String jti = IdUtil.simpleUUID();

    public JwtPayloadBuilder iss(String iss) {
        this.iss = iss;
        return this;
    }


    public JwtPayloadBuilder sub(String sub) {
        this.sub = sub;
        return this;
    }

    public JwtPayloadBuilder aud(String aud) {
        this.aud = aud;
        return this;
    }


    public JwtPayloadBuilder roles(Set<String> roles) {
        this.roles = roles;
        return this;
    }

    public JwtPayloadBuilder expDays(int days) {
        Assert.isTrue(days > 0, "jwt expireDate must after now");
        this.exp = this.iat.plusDays(days);
        return this;
    }

    public JwtPayloadBuilder additional(Map<String, String> additional) {
        this.additional = additional;
        return this;
    }

    public String builder() {
        payload.put("iss", this.iss);
        payload.put("sub", this.sub);
        payload.put("aud", this.aud);
        payload.put("exp", this.exp.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        payload.put("iat", this.iat.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        payload.put("jti", this.jti);

        if (!CollectionUtils.isEmpty(additional)) {
            payload.putAll(additional);
        }
        payload.put("roles", JSONUtil.toJsonStr(this.roles));
        return JSONUtil.toJsonStr(JSONUtil.parse(payload));

    }

}

通过建造类 JwtClaimsBuilder 我们可以很方便来构建 JWT 所需要的 payload json 字符串传递给 encode(CharSequence content, Signer signer) 中的 content

3.2 生成 RSA 密钥并进行签名

为了生成 JWT Token 我们还需要使用 RSA 算法来进行签名。 这里我们使用 JDK 提供的证书管理工具 Keytool 来生成 RSA 证书 ,格式为 jks 格式。

生成证书命令参考:

 keytool -genkey -alias felordcn -keypass felordcn -keyalg RSA -storetype PKCS12 -keysize 1024 -validity 365 -keystore d:/keystores/felordcn.jks -storepass 123456  -dname "CN=(Felord), OU=(felordcn), O=(felordcn), L=(zz), ST=(hn), C=(cn)"

其中 -alias felordcn -storepass 123456 我们要作为配置使用要记下来。我们要使用下面定义的这个类来读取证书

 package cn.felord.spring.security.jwt;

 import org.springframework.core.io.ClassPathResource;

 import java.security.KeyFactory;
 import java.security.KeyPair;
 import java.security.KeyStore;
 import java.security.PublicKey;
 import java.security.interfaces.RSAPrivateCrtKey;
 import java.security.spec.RSAPublicKeySpec;

 /**
  * KeyPairFactory
  *
  * @author Felordcn
  * @since 13:41 2019/10/25
  **/
 class KeyPairFactory {

     private KeyStore store;

     private final Object lock = new Object();

     /**
      * 获取公私钥.
      *
      * @param keyPath  jks 文件在 resources 下的classpath
      * @param keyAlias  keytool 生成的 -alias 值  felordcn
      * @param keyPass  keytool 生成的  -storepass 值  123456
      * @return the key pair 公私钥对
      */
    KeyPair create(String keyPath, String keyAlias, String keyPass) {
         ClassPathResource resource = new ClassPathResource(keyPath);
         char[] pem = keyPass.toCharArray();
         try {
             synchronized (lock) {
                 if (store == null) {
                     synchronized (lock) {
                         store = KeyStore.getInstance("jks");
                         store.load(resource.getInputStream(), pem);
                     }
                 }
             }
             RSAPrivateCrtKey key = (RSAPrivateCrtKey) store.getKey(keyAlias, pem);
             RSAPublicKeySpec spec = new RSAPublicKeySpec(key.getModulus(), key.getPublicExponent());
             PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(spec);
             return new KeyPair(publicKey, key);
         } catch (Exception e) {
             throw new IllegalStateException("Cannot load keys from store: " + resource, e);
         }

     }
 }

获取了 KeyPair 就能获取公私钥 生成 Jwt 的两个要素就完成了。我们可以和之前定义的 JwtPayloadBuilder 一起封装出生成 Jwt Token 的方法:

     private String jwtToken(String aud, int exp, Set<String> roles, Map<String, String> additional) {
         String payload = jwtPayloadBuilder
                 .iss(jwtProperties.getIss())
                 .sub(jwtProperties.getSub())
                 .aud(aud)
                 .additional(additional)
                 .roles(roles)
                 .expDays(exp)
                 .builder();
         RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

         RsaSigner signer = new RsaSigner(privateKey);
         return JwtHelper.encode(payload, signer).getEncoded();
     }

通常情况下 Jwt Token 都是成对出现的,一个为平常请求携带的 accessToken , 另一个只作为刷新 accessToken 之用的 refreshToken 。而且 refreshToken 的过期时间要相对长一些。当 accessToken 失效而 refreshToken 有效时,我们可以通过 refreshToken 来获取新的 Jwt Token对 ;当两个都失效就用户就必须重新登录了。

生成 Jwt Token对 的方法如下:

     public JwtTokenPair jwtTokenPair(String aud, Set<String> roles, Map<String, String> additional) {
         String accessToken = jwtToken(aud, jwtProperties.getAccessExpDays(), roles, additional);
         String refreshToken = jwtToken(aud, jwtProperties.getRefreshExpDays(), roles, additional);

         JwtTokenPair jwtTokenPair = new JwtTokenPair();
         jwtTokenPair.setAccessToken(accessToken);
         jwtTokenPair.setRefreshToken(refreshToken);
         // 放入缓存
         jwtTokenStorage.put(jwtTokenPair, aud);
         return jwtTokenPair;
     }

通常 Jwt Token对 会在返回给前台的同时放入缓存中。过期策略你可以选择分开处理,也可以选择以 refreshToken 的过期时间为准。

4. JWT 解码以及验证

JwtHelper 提供的第二个静态方法是 Jwt decodeAndVerify(String token, SignatureVerifier verifier) 用来 验证和解码 Jwt Token 。我们获取到请求中的token后会解析出用户的一些信息。通过这些信息去缓存中对应的token ,然后比对并验证是否有效(包括是否过期)。

     /**
      * 解码 并校验签名 过期不予解析
      *
      * @param jwtToken the jwt token
      * @return the jwt claims
      */
     public JSONObject decodeAndVerify(String jwtToken) {
         Assert.hasText(jwtToken, "jwt token must not be bank");
         RSAPublicKey rsaPublicKey = (RSAPublicKey) this.keyPair.getPublic();
         SignatureVerifier rsaVerifier = new RsaVerifier(rsaPublicKey);
         Jwt jwt = JwtHelper.decodeAndVerify(jwtToken, rsaVerifier);
         String claims = jwt.getClaims();
         JSONObject jsonObject = JSONUtil.parseObj(claims);
         String exp = jsonObject.getStr(JWT_EXP_KEY);
        // 是否过期
         if (isExpired(exp)) {
             throw new IllegalStateException("jwt token is expired");
         }
         return jsonObject;
     }

上面我们将有效的 Jwt Token 中的 payload 解析为 JSON对象 ,方便后续的操作。

5. 配置

我们将 JWT 的可配置项抽出来放入 JwtProperties 如下:

/**
 * Jwt 在 springboot application.yml 中的配置文件
 *
 * @author Felordcn
 * @since 15 :06 2019/10/25
 */
@Data
@ConfigurationProperties(prefix=JWT_PREFIX)
public class JwtProperties {
    static final String JWT_PREFIX= "jwt.config";
    /**
     * 是否可用
     */
    private boolean enabled;
    /**
     * jks 路径
     */
    private String keyLocation;
    /**
     * key alias
     */
    private String keyAlias;
    /**
     * key store pass
     */
    private String keyPass;
    /**
     * jwt签发者
     **/
    private String iss;
    /**
     * jwt所面向的用户
     **/
    private String sub;
    /**
     * access jwt token 有效天数
     */
    private int accessExpDays;
    /**
     * refresh jwt token 有效天数
     */
    private int refreshExpDays;
}

然后我们就可以配置 JWTjavaConfig 如下:

 /**
  * JwtConfiguration
  *
  * @author Felordcn
  * @since 16 :54 2019/10/25
  */
 @EnableConfigurationProperties(JwtProperties.class)
 @ConditionalOnProperty(prefix = "jwt.config",name = "enabled")
 @Configuration
 public class JwtConfiguration {


     /**
      * Jwt token storage .
      *
      * @return the jwt token storage
      */
     @Bean
     public JwtTokenStorage jwtTokenStorage() {
         return new JwtTokenCacheStorage();
     }


     /**
      * Jwt token generator.
      *
      * @param jwtTokenStorage the jwt token storage
      * @param jwtProperties   the jwt properties
      * @return the jwt token generator
      */
     @Bean
     public JwtTokenGenerator jwtTokenGenerator(JwtTokenStorage jwtTokenStorage, JwtProperties jwtProperties) {
         return new JwtTokenGenerator(jwtTokenStorage, jwtProperties);
     }

 }

然后你就可以通过 JwtTokenGenerator 编码/解码验证 Jwt Token 对 ,通过 JwtTokenStorage 来处理 Jwt Token 缓存。缓存这里我用了 Spring Cache Ehcache 来实现,你也可以切换到 Redis 。相关单元测试参见 DEMO

6. 总结

今天我们利用 spring-security-jwt 手写了一套 JWT 逻辑。无论对你后续结合 Spring Security 还是 Shiro 都十分有借鉴意义。下一篇我们会讲解 JWT 结合 Spring Security ,敬请关注公众号: Felordcn 来及时获取资料。

本次的 DEMO 也可通过关注公众号回复 day05 获取。


原文:Spring Security 实战干货:手把手教你实现JWT Token | Java|Spring Security|Spring Boot|Spring Cloud|https://felord.cn 码农小胖哥的博客