一、什么是JWT

由于HTTP协议是无状态的,这意味着如果我们想判定一个接口是否被认证后访问,就需要借助cookie或者session会话机制进行判定,但是由于现在的系统架构大部分都不止一台服务器,此时又要借助数据库或者全局缓存 做存储,这种方案显然受限太多。

那么我们可不可以让认证令牌的发布者自己去识别这个令牌是不是我曾经发布的令牌呢(JWT核心思想),这是JWT最大的优点也是最大的缺点,优点是简单快捷、不需要依赖任何第三方操作就能实现身份认证,缺点就是对于任何拥有用户发布令牌的请求都会认证通过。

在介绍JWT之前,我们先来回顾一下利用token进行用户身份验证的流程:

  1. 客户端使用用户名和密码请求登录
  2. 服务端收到请求,验证用户名和密码
  3. 验证成功后,服务端会签发一个token,再把这个token返回给客户端
  4. 客户端收到token后可以把它存储起来,比如放到cookie中
  5. 客户端每次向服务端请求资源时需要携带服务端签发的token,可以在cookie或者header中携带
  6. 服务端收到请求,然后去验证客户端请求里面带着的token,如果验证成功,就向客户端返回请求数据

这种基于token的认证方式相比传统的session认证方式更节约服务器资源,并且对移动端和分布式更加友好。其优点如下:

支持跨域访问:cookie是无法跨域的,而token由于没有用到cookie(前提是将token放到请求头中),所以跨域后不会存在信息丢失问题
无状态:token机制在服务端不需要存储session信息,因为token自身包含了所有登录用户的信息,所以可以减轻服务端压力
适用CDN:可以通过内容分发网络请求服务端的所有资料
适用移动端:当客户端是非浏览器平台时,cookie是不被支持的,此时采用token认证方式会简单很多
无需考虑CSRF:由于不再依赖cookie,所以采用token认证方式不会发生CSRF,所以也就无需考虑CSRF的防御

而JWT就是上述流程当中token的一种具体实现方式,JSON Web Token(缩写 JWT)JWT官网

通俗地说,JWT的本质就是一个字符串,它是将用户信息保存到一个Json字符串中,然后进行编码后得到一个JWT token,并且这个JWT token带有签名信息,接收后可以校验是否被篡改,所以可以用于在各方之间安全地将信息作为Json对象传输。JWT的认证流程如下:

  1. 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口,这个过程一般是一个POST请求。建议的方式是通过SSL加密的传输(HTTPS),从而避免敏感信息被嗅探
  2. 后端核对用户名和密码成功后,将包含用户信息的数据作为JWT的Payload,将其与JWT Header分别进行Base64编码拼接后签名,形成一个JWT Token,形成的JWT Token就是一个如同lll.zzz.xxx的字符串
  3. 后端将JWT Token字符串作为登录成功的结果返回给前端。前端可以将返回的结果保存在浏览器中,退出登录时删除保存的JWT Token即可
  4. 前端在每次请求时将JWT Token放入HTTP请求头中的Authorization属性中(解决XSS和XSRF问题)
  5. 后端检查前端传过来的JWT Token,验证其有效性,比如检查签名是否正确、是否过期、token的接收方是否是自己等等
  6. 验证通过后,后端解析出JWT Token中包含的用户信息,进行其他逻辑操作(一般是根据用户信息得到权限等),返回结果
    在这里插入图片描述

二、JWT的数据结构

正常的JWT数据结构应该如下
在这里插入图片描述
它是一个很长的字符串,中间用点(.)分隔成三个部分
JWT的三个部分依次: Header - 头部 、Payload - 负载 、Signature(签名)
即:Header.Payload.Signature

1. header

Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子。

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

alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);
typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT

2. Payload

Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。

iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号

除了官方字段,你还可以在这个部分定义私有字段

{
"sub": "1234567890",
"name": "John Doe",
"age": "19"
}

注意:JWT默认是明文展示,任何人都可以读取到,所以此处不要放私密信息
这个 JSON 对象也要使用 Base64URL 算法转成字符串。

3. Signature

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。

4. Base64URL

前面提到,Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。

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

三、JWT的实现

1. Maven依赖

<dependency>
		    <groupId>com.auth0</groupId>
		    <artifactId>java-jwt</artifactId>
		    <version>3.5.0</version>
</dependency>

2. JWT生成token和验签代码

@Slf4j
public class JWTUtil {

    private static final String SECRET = "!Q@W#E$R^Y&U";
    //token签发者
    private static final String ISSUSRE = "HZSTYGZPT";
    //token过期时间
    public static final Long EXPIRE_DATE = 1000*60L;

    /**
     *  生成token
     * @param userVo
     * @return
     */
    public static String Token(UserVo userVo){
        //当前时间
        Date now = new Date();
        //创建过期时间
        Calendar instance = Calendar.getInstance();
        instance.add(Calendar.DATE,7);  //7天过期
        //1. header
        Algorithm algorithm = Algorithm.HMAC256(SECRET);
        String token = JWT.create()
                .withIssuer(ISSUSRE)
                .withIssuedAt(now)
                .withExpiresAt(new Date(now.getTime() + EXPIRE_DATE))
                .withClaim("username", userVo.getUsername())
                .withClaim("userId", userVo.getUserId())
                .sign(algorithm);
        return token;
    }

    /**
     *  生成token
     * @param map
     * @return
     */
    public static String createToken(Map<String,String> map){
        //创建过期时间
        Calendar instance = Calendar.getInstance();
        instance.add(Calendar.DATE,7);  //7天过期

        //创建builder对象
        JWTCreator.Builder builder = JWT.create();
        //遍历map
        map.forEach((k,v)->{
            builder.withClaim(k,v);
        });
        String token = builder.withExpiresAt(instance.getTime()).sign(Algorithm.HMAC256(SECRET));
        return token;
    }

    /**
     *  验证token
     *  验证过程中如果有异常,则抛出;
     *  如果没有,则返回 DecodedJWT 对象来获取用户信息;
     * @param token
     */
    public static JsonResult verifyToken(String token, String username){
        Algorithm algorithm = Algorithm.HMAC256(SECRET);
        try {
            JWTVerifier jwtVerifier = JWT.require(algorithm).withClaim("username", username).build();
            jwtVerifier.verify(token);
            return new JsonResult();
        }catch (SignatureVerificationException e) {
            //验证的签名不一致
            throw new SignatureVerificationException(algorithm);
        }catch (TokenExpiredException e){
            throw new TokenExpiredException("token is alreadey expired");
        }catch (AlgorithmMismatchException e){
            throw new AlgorithmMismatchException("签名算法不匹配");
        }catch (InvalidClaimException e){
            throw new InvalidClaimException("校验的claims内容不匹配");
        }catch (Exception e){
            e.printStackTrace();
        }
        return new JsonResult().error("用户和jwt-token校验失败");
    }

    /**
     *  验证token
     *  验证过程中如果有异常,则抛出;
     *  如果没有,则返回 DecodedJWT 对象来获取用户信息;
     * @param token
     */
    public static DecodedJWT verify(String token){
        return JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token);
    }

3. 拦截器配置无需认证的请求

@Configuration
public class InterceptorConfig extends WebMvcConfigurationSupport {

    /**
     * 注册拦截器
     * @return
     */
    @Bean
    public JwtInterceptor getJwtInterceptor(){
        return new JwtInterceptor();
    }

    @Override
    protected void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(getJwtInterceptor())
                .addPathPatterns("/**")   //其他接口保护 token验证
                .excludePathPatterns("/user/**")  //所有用户接口都放行
                .excludePathPatterns("/favicon.ico");
    }

    /**
     * 解决 No mapping for GET /favicon.ico 访问静态资源图标
     * @param registry
     */
    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        //addResourceHandler("/qy/**") 所有/qy/开头的请求 都会去后面配置的路径下查找资源
        registry.addResourceHandler("/favicon.ico").addResourceLocations("classpath:/static/");
        //图片真实存放的路径
        //registry.addResourceHandler("/upload/avatar/**").addResourceLocations("file:"+System.getProperty("user.dir")+"/upload/avatar/");
        super.addResourceHandlers(registry);
    }
}

或者

@Configuration
public class InterceptorConfig extends WebMvcConfigurationSupport  {
   
	@Autowired
	private TokenHandler tokenHandler;
    


    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        List<String> excludePath = new ArrayList<>();
        String checkLogin = "/pushlogin/checkIsCanLogin";
        String login = "/pushlogin/login";
        String getVerifyCode = "/common/send";
        String verfifyMethod = "/common/validationCode";
        excludePath.add(checkLogin);
        excludePath.add(login);
        excludePath.add(getVerifyCode);
        excludePath.add(verfifyMethod);
        registry.addInterceptor(tokenHandler).excludePathPatterns(excludePath);
    }
}

4. Token统一拦截器代码

public class JwtInterceptor implements HandlerInterceptor {
    private static final Logger log = LoggerFactory.getLogger(JwtInterceptor.class);

    private static final String AUTH = "authorization";
    private static final String AUTH_USER_NAME = "user-name";

    @Value("${spring.profiles.active}")
    private String profiles;


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        response.setContentType("application/json;charset=utf-8");

        //如果是开发环境,则不需要token。直接通过
        if(StrUtil.isNotEmpty(profiles) && profiles.equals("dev")){
            return true;
        }


        //如果接口或者类上有@IgnoreToken注解,意思该接口不需要token就能访问,需要放行
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        //先从类上获取该注解,判断类上是否加了IgnoreToken ,代表不需要token,直接放行
        IgnoreToken annotation = handlerMethod.getBeanType().getAnnotation(IgnoreToken.class);
        if(annotation == null){
            //再从方法上获取该注解
            if(method.isAnnotationPresent(IgnoreToken.class)){
                //annotation = handlerMethod.getMethodAnnotation(IgnoreToken.class);
                annotation = method.getAnnotation(IgnoreToken.class);
                log.info("请求方法 {} 上有注解 {} ",method.getName(),annotation);
            }
        }
        if(annotation != null){
            return true;
        }

        String token = getParams(request, AUTH);
        String username = getParams(request, AUTH_USER_NAME);

        if (StrUtil.isEmpty(token)) {
            throw new ValidationException("Authorization不允许为空,请重新登录");
        }

        if (StrUtil.isEmpty(username)) {
            throw new ValidationException("用户名不允许为空,请重新登录");
        }
        JsonResult jsonResult = JWTUtil.verifyToken(token, username);
        if (jsonResult.getCode() != 200) {
            log.error("token valid error!");
            return false;
        }
        return true;
    }

或者

@Component
@Slf4j
public class TokenHandler implements HandlerInterceptor{
	
		@Override
	   public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)  throws Exception {
	 
	        String token = request.getHeader("Authentication");
	        if (token != null){
	            boolean result = TokenUtil.verify(token);
	            if(result){
	                log.info("通过拦截器");
	                return true;
	            }
	        }
	        log.info("认证失败");
	        
	        return false;
	   }
	
}

5. 用户登录时验证用户信息后,返回Token信息

 @PostMapping("/user/jwtLogin")
    public JsonResult jwtLogin(String username, String password,HttpServletResponse response)  {
        //1. 判断用户名密码是否为空
        if(StrUtil.isBlank(username) || StrUtil.isBlank(password)) {
            throw new IllegalArgumentException("用户名或者密码为空");
        }

        MUser user = userService.getUserByUsername(username);

        if(user ==null) {
            throw new ValidationException("用户名或者密码错误");
        }
        //对密码进行加密
        password = SecureUtil.md5(password);
        if(!password.equalsIgnoreCase(user.getPassword())){
            throw new ValidationException("用户名或者密码错误");
        }

        UserVo userVo = new UserVo();
        userVo.setUserId(user.getId());
        userVo.setUsername(user.getUsername());

        //生成token
        String token = JWTUtil.Token(userVo);

        //写入token
        userVo.setToken(token);
        //刷新token的key
        userVo.setRefreshToken(UUID.randomUUID().toString());
        //存储用户信息到redis中
        redisTemplate.opsForValue().set(userVo.getRefreshToken(),userVo,JWTUtil.EXPIRE_DATE, TimeUnit.SECONDS);

        response.setHeader("authorization",token);
        //response.setHeader("user-name",username);

        return new JsonResult<>(userVo);

    }

6. 续签的方法

@GetMapping("/user/refresh")
    public JsonResult refreshToken(String refreshToken){
        //根据 key 为 refreshToken,获取当前登录的用户信息是否还在有效期内
        UserVo userVo = (UserVo) redisTemplate.opsForValue().get(refreshToken);

        if(userVo ==null){
            //告诉用户 token已经失效,需要重新登录
            return new JsonResult().error("用户信息已不存在,需重新登录");
        }

        //重新生成token
        String jwt = JWTUtil.Token(userVo);
        userVo.setToken(jwt);
        userVo.setRefreshToken(UUID.randomUUID().toString());


        //删除之前的用户信息
        redisTemplate.delete(refreshToken);
        //将刷新后的值存入redis  
        redisTemplate.opsForValue().set(userVo.getRefreshToken(),userVo,JWTUtil.EXPIRE_DATE, TimeUnit.SECONDS);
        return new JsonResult<>(userVo);
    }

四、学习中的坑

1.在使用jjwt创建JWT时setSubject和setClaims值覆盖

  1. 在创建jwt过程中,如果前面已经设置了setSubject,后面再设置setClaims的时候,会将前面内置的subject内容覆盖掉,但是claims里面的内容可以正常显示;原因是在setClaims的方法里面,已经有setSubject,所以会将之前设置的内容覆盖掉。
    解决方法:
    (1)使用setClaims这个方法的时候要将它移动到上面,避免覆盖。
    例如:改之前代码:
 String token = Jwts.builder().setHeaderParam("typ", "JWT")
                .setSubject("11221")
                .setClaims(map)
                .setIssuedAt(nowDate)
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS256,SIGN_KEY)
                .compact();

测试结果:
在这里插入图片描述
改之后代码块:

String token = Jwts.builder().setHeaderParam("typ", "JWT")
                .setClaims(map)
                .setSubject("11221")
                .setIssuedAt(nowDate)
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS256,SIGN_KEY)
                .compact();

测试结果:
在这里插入图片描述
(2)不使用 builder.setClaims(map) 封装 。

@Test
    void createJWT(){
        Map<String,Object> map = new HashMap<>();
        map.put("userId","1111");
        map.put("name","张某");
        Date nowDate = new Date();
        Date expireDate = new Date(nowDate.getTime()+604800*1000);
        System.out.println("=======第一种写法=======");
        String token = Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setClaims(map)
                .setSubject("11221213131")
                .setIssuedAt(nowDate)
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS256,SIGN_KEY)
                .compact();
        System.out.println(token);

        System.out.println("=======第二种写法=======");
        JwtBuilder jwtBuilder = Jwts.builder().setHeaderParam("typ", "JWT")
                .setSubject("11221")
                .setIssuedAt(nowDate)
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS256, SIGN_KEY);
        //第一种
        map.forEach((k,v)->{
            jwtBuilder.claim(k,v);
        });
        //第二种
        Set<String> keys = map.keySet();
        for (String key : keys){
            jwtBuilder.claim(key,map.get(key));
        }
        //第三种
        Set<Map.Entry<String, Object>> entries = map.entrySet();
        for (Map.Entry<String, Object> entry : entries){
            jwtBuilder.claim(entry.getKey(),entry.getValue());
        }
        String token1 = jwtBuilder.compact();
        System.out.println(token1);
  1. 载荷部分存在两个属性:payload和claims。两个属性均可作为载荷,jjwt中二者只能设置其一,如果同时设置,在终端方法compact() 中将抛出异常。
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐