SpringBoot2.x简单使用JWT

什么是JWT

JSON Web Token (JWT) 是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于作为JSON对象在各方之间安全地传输信息。
关于JWT的更多的介绍可以参考JWT的官网:https://jwt.io/

基于SpringBoot 2.x来使用JWT

Java使用jwt可以参考GitHub上官方的介绍:https://github.com/jwtk/jjwt

  1. 创建一个SpringBoot工程
    相关目录结构如下图所示
    SpringBoot2.x简单使用JWT
  2. 添加相关依赖及其版本
    该项目用到的依赖如下图所示:SpringBoot2.x简单使用JWT
    pom.xml文件中依赖部分代码为:
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.8.0</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.0</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.54</version>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>1.3.2</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

说明
(1)lombok是用于减少显示地编写实体类地getter/setter、构造器等方法的工具,除了要引入依赖,所用的IDE也需要安装相关插件,安装Lombok插件可参考–网址–。
(2)java-jwt、jjwt是使用jwt的相关依赖,我用的jjwt是0.9.0版本的,目前最新的是0.10.5版本,有些方法有所变动。(若使用了最新版本,最好是参考GitHub上的官方教程)
(3)fastjson是阿里开源的处理json数据于Java对象之间转换的Java库。

  1. springboot使用mybatis
    数据库中数据
    SpringBoot2.x简单使用JWT

(1) 创建User实体类

package com.springboot.jwtdemo2.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @Description: 用户实体类,使用Lombok相关注解来实现构造器和getter、setter
 * @Author 傅琦
 * @Date 2019/4/12 21:30
 * @Version V1.0
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
    /**
     * 用户id
     */
    private int userId;
    /**
     * 用户名
     */
    private String username;
    /**
     * 用户密码
     */
    private String password;
}

(2) 编写持久层接口UserMapper.java

package com.springboot.jwtdemo2.mapper;

import com.springboot.jwtdemo2.pojo.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

/**
 * @Description: 用户数据持久层接口,其实现为userMapper.xml文件
 * @Author 傅琦
 * @Date 2019/4/12 21:39
 * @Version V1.0
 */
@Mapper
public interface UserMapper {
    /**
     * 根据用户姓名进行查找
     * @param username
     * @return User
     */
    User findByUsername(@Param("username") String username);

    /**
     * 根据用户id进行查找
     * @param userId
     * @return User
     */
    User findById(@Param("userId") int userId);
}

(3) 编写mybatis的SQL映射文件

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.springboot.jwtdemo2.mapper.UserMapper">
    
    <select id="findByUsername" resultType="com.springboot.jwtdemo2.pojo.User">
        SELECT
        id           AS "userId",
        user_name    AS "username",
        password     AS "password"
        FROM jwt_user
        WHERE
        user_name = #{username}
        LIMIT 1
    </select>

    <select id="findById" resultType="com.springboot.jwtdemo2.pojo.User">
        SELECT
        id           AS "userId",
        user_name    AS "username",
        password     AS "password"
        FROM jwt_user
        WHERE
        id = #{userId}
        LIMIT 1
    </select>
</mapper>

(4) 编写项目配置文件中的相关配置,并对UserMapper进行单元测试
application.yml

# 配置提供服务的端口
server:
  port: 9000

# 数据库的相关配置
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://172.16.9.96:3307/springboot?useSSL=false&useUnicode=true&characterEncoding=utf8
    username: root
    password: 123456

# mybatis的相关配置
mybatis:
  mapper-locations: classpath:mapper/*.xml

现在就可对UserMapper进行单元测试

package com.springboot.jwtdemo2.mapper;

import com.springboot.jwtdemo2.pojo.User;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import static org.junit.Assert.*;

/**
 * @Description: UserMapper的测试类
 * @Author 傅琦
 * @Date 2019/4/12 22:00
 * @Version V1.0
 */
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserMapperTest {
    @Autowired
    private UserMapper userMapper;

    @Test
    public void testMapper(){
        assert userMapper != null;
    }

    @Test
    public void findByUsername() {
        String name = "李雷";
        User user = userMapper.findByUsername(name);
        if (user == null){
            System.out.println("该用户不存在");
        }else {
            System.out.println("the user is: " + user.getUsername());
        }
    }

    @Test
    public void findById() {
        User user = userMapper.findById(2);
        if (user == null){
            System.out.println("该用户不存在");
        }else {
            System.out.println("the user is: " + user.getUsername());
        }
    }
}

单元测试完全通过,说明UserMapper没有问题,可再往上对service层进行开发。

(5) 编写UserResult.java和UserService.java、UserServiceImpl.java,在service层对查询数据进行封装,并对UserService进行单元测试
这里在service层对Mapper层的返回结果进行封装,需要一个结果封装类,所以先白那些UserResult.java

package com.springboot.jwtdemo2.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @Description: 用户查询结果的封装
 * @Author 傅琦
 * @Date 2019/4/12 22:56
 * @Version V1.0
 */
@AllArgsConstructor
@Data
@NoArgsConstructor
public class UserResult<T> {
    private String state;
    private T data;
}

再编写UserService.java

package com.springboot.jwtdemo2.service;

import com.springboot.jwtdemo2.pojo.User;
import com.springboot.jwtdemo2.dto.UserResult;

/**
 * @Description: 用户服务层接口
 * @Author 傅琦
 * @Date 2019/4/12 22:46
 * @Version V1.0
 */
public interface UserService {
    /**
     * 根据用户名查询用户
     * @param username
     * @return User
     */
    UserResult<User> findByUsername(String username);

    /**
     * 根据用户id查询用户
     * @param userId
     * @return User
     */
    UserResult<User> findById(int userId);
}

以及UserService.java的实现类UserServiceImpl.java

package com.springboot.jwtdemo2.service.serviceimpl;

import com.springboot.jwtdemo2.mapper.UserMapper;
import com.springboot.jwtdemo2.pojo.User;
import com.springboot.jwtdemo2.service.UserService;
import com.springboot.jwtdemo2.dto.UserResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
 * @Description: 用户服务层接口的实现
 * @Author 傅琦
 * @Date 2019/4/12 22:51
 * @Version V1.0
 */
@Service("UserService")
public class UserServiceImpl implements UserService {
    @Autowired
    private UserMapper userMapper;

    @Override
    public UserResult<User> findByUsername(String username) {
        User user = userMapper.findByUsername(username);
        if (user == null){
            return new UserResult<>("fail", null);
        }else {
            return new UserResult<>("success", user);
        }
    }

    @Override
    public UserResult<User> findById(int userId) {
        User user = userMapper.findById(userId);
        if (user == null){
            return new UserResult<>("fail", null);
        }else {
            return new UserResult<>("success", user);
        }
    }
}

由于这里逻辑比较简单,可以不用进行单元测试,如果嫌麻烦的话,可以跳过UserService的单元测试。

  1. JwtUtil.java的编写及说明
    在编写了对用户的查询操作后,可开始JWT相关操作的开发,编写JwtUtil.java
package com.springboot.jwtdemo2.utils;

import com.springboot.jwtdemo2.commons.Constant;
import com.springboot.jwtdemo2.pojo.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.apache.tomcat.util.codec.binary.Base64;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

/**
 * @Description: JWT工具类
 * @Author 傅琦
 * @Date 2019/4/13 9:29
 * @Version V1.0
 */
public class JwtUtil {
    /**
     * 生成**
     * @return SecretKey
     */
    private static SecretKey generalKey(){
        String stringKey = Constant.JWT_SECRET;
        byte[] encodedKey = Base64.decodeBase64(stringKey);
        SecretKey secretKey = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
        return secretKey;
//        SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
//        return key;
    }

    /**
     * 根据用户信息为其签发tocken
     * @param user
     * @return String
     */
    public static String generalTocken(User user){
        try {
            // 设置签发算法
            SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
            // 生成**
            SecretKey key = generalKey();
//            System.out.println("签发时生成的key为:" + key);
            // 设置私有声明
            Map<String, Object> claims = new HashMap<>(16);
            claims.put("userId", user.getUserId());
            claims.put("username", user.getUsername());
            // 记录生成JWT的时间
            long nowMillis = System.currentTimeMillis();
            Date nowTime = new Date(nowMillis);
            // 设置过期时间
            long expMillis = nowMillis + Constant.EXP_TIME_LENGTH;
            Date expTime = new Date(expMillis);
            // 创建tocken构建器实例
            JwtBuilder jwtBuilder = Jwts.builder()
                    // 设置自己的私有声明
                    .setClaims(claims)
                    // 设置该tocken的Id,用于防止tocken重复
                    .setId(UUID.randomUUID().toString())
                    // 设置签发者
                    .setIssuer("FUQI-PC")
                    // 设置签发时间
                    .setIssuedAt(nowTime)
                    // 设置过期时间
                    .setExpiration(expTime)
                    // 设置tocken的签发对象
                    .setSubject("users")
                    // 设置签发算法和**
                    .signWith(signatureAlgorithm, key); 
            return jwtBuilder.compact();
        } catch (Exception e) {
            e.printStackTrace();
            return "生成tocken失败";
        }
    }

    /**
     * 解析tocken,从中提取出声明信息
     * @param tocken
     * @return Claims
     * @throws Exception
     */
    public static Claims parseTocken(String tocken) throws Exception{
        SecretKey key = generalKey();
//        System.out.println("解析tocken时生成的key为:" + key);
        // 获取tocken中的声明部分
        Claims claims = Jwts.parser()
                .setSigningKey(key)
                .parseClaimsJws(tocken).getBody();
        return claims;
    }
}

由于我对**、证书等概念还不熟,所以签发tocken使用的**是用方法生成的。

  1. 控制器UserController.java的编写
package com.springboot.jwtdemo2.controller;

import com.alibaba.fastjson.JSONObject;
import com.springboot.jwtdemo2.dto.UserResult;
import com.springboot.jwtdemo2.pojo.User;
import com.springboot.jwtdemo2.service.UserService;
import com.springboot.jwtdemo2.utils.JwtUtil;
import com.springboot.jwtdemo2.vo.UserViewObject;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;

/**
 * @Description: 用户控制器
 * @Author 傅琦
 * @Date 2019/4/13 15:21
 * @Version V1.0
 */
@RestController
@RequestMapping("user")
public class UserController {
    @Autowired
    private UserService userService;

    @PostMapping("/login")
    public UserViewObject<Object> userLongin(@RequestBody User user){
        UserViewObject<Object> result = new UserViewObject<>();
        UserResult<User> userResult = userService.findByUsername(user.getUsername());
        if ("fail".equals(userResult.getState())){
            result.setState("fail");
            result.setData("用户不存在,请先注册");
        }else {
            User user1 = userResult.getData();
            if (!user.getPassword().equals(user1.getPassword())){
                result.setState("fail");
                result.setData("密码错误,请重新输入");
            }else {
                String tocken = JwtUtil.generalTocken(user1);
                JSONObject jsonObject = new JSONObject();
                jsonObject.put("user", user1);
                jsonObject.put("tocken", tocken);
                result.setState("sucees");
                result.setData(jsonObject);
            }
        }
        return result;
    }

    @PostMapping("/whoami")
    public String getMessage(HttpServletRequest httpServletRequest) {
        try {
            String tocken = httpServletRequest.getHeader("tocken");
            Claims claims = JwtUtil.parseTocken(tocken);
            String username = claims.get("username", String.class);
            System.out.println("执行处理器中的控制器中的对应方法中的方法体。");
            return username + ",你已通过验证";
        } catch (Exception e) {
            e.printStackTrace();
            return "处理tocken出现错误。";
        }
    }
}

  1. 拦截器AuthenticationInterceptor.java的编写
package com.springboot.jwtdemo2.interceptor;

import com.springboot.jwtdemo2.dto.UserResult;
import com.springboot.jwtdemo2.pojo.User;
import com.springboot.jwtdemo2.service.UserService;
import com.springboot.jwtdemo2.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @Description: 自定义的拦截器,用于对tocken以及其中所包含的信息进行校验
 * @Author 傅琦
 * @Date 2019/4/13 19:56
 * @Version V1.0
 */
public class AuthenticationInterceptor implements HandlerInterceptor {
    @Autowired
    private UserService userService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 如果不是映射到方法,则直接通过
        if (!(handler instanceof HandlerMethod)){
            return true;
        }
        // 从请求头中获取tocken
        String tocken = request.getHeader("tocken");
//        System.out.println(tocken);
        // 当没有获取到tocken时的处理
        if (tocken == null){
            response.sendError(403, "无tocken,请先登录");
            return false;
        }else {
            try {
                // tocken还未过期时的处理
                // 获取声明部分
                Claims claims = JwtUtil.parseTocken(tocken);
                String username = claims.get("username", String.class);
                // userService导入存在问题时的处理
                if (userService == null){
                    System.out.println("无法导入userService。");
                    response.sendError(500, "服务器发生错误");
                    return false;
                }
                // 查询用户信息
                UserResult<User> userResult = userService.findByUsername(username);
                if ("success".equals(userResult.getState())){
                    return true;
                }else {
                    response.sendError(401, "用户不存在,请先注册");
                    return false;
                }
            }catch (ExpiredJwtException exp){
                // tocken过期时的处理
                response.sendError(40102, "tocken过期,请重新登录");
                return false;
            }
        }
//        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        System.out.println("处理器完成后的方法,此时说明控制器已经执行完毕。");
    }
}

拦截器将请求拦截下来,并取出tocken进行解析,从而判断是否放行该请求。
实现拦截器就需要实现HandlerInterceptor接口,该接口是Java 8 的接口,所以其中3个方法都被声明为default,并且提供了空实现。当我们需要自己编写拦截判断逻辑时,只需实现HandlerInterceptor,覆盖对应的方法即可。
其中比较重要的是preHandle方法。preHandle方法是在处理器(包含了控制器的功能)执行前执行的方法,返回值是bool值。若返回true,则放行请求,由处理器对请求进行处理;若返回false,则拦住该请求,结束所有流程。

  1. 注册拦截器
package com.springboot.jwtdemo2.config;

import com.springboot.jwtdemo2.interceptor.AuthenticationInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @Description: 注册验证tocken的拦截器
 * @Author 傅琦
 * @Date 2019/4/13 20:41
 * @Version V1.0
 */
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    @Bean
    public AuthenticationInterceptor authenticationInterceptor(){
        return new AuthenticationInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authenticationInterceptor()).addPathPatterns("/user/whoami");
    }
}

InterceptorRegistry内的addInterceptor需要一个实现HandlerInterceptor接口的拦截器实例,addPathPatterns方法用于设置拦截器的过滤路径规则。
在配置类上添加了注解@Configuration,标明了该类是一个配置类并且会将该类作为一个SpringBean添加到IOC容器内。

  1. 使用postman进行测试
    经过前面几个步骤之后,一个简单的基于springboot 2.x来使用JWT的工程就已经完成了。接下来就是运行起该工程,使用postman对其进行测试。
    (1)先测试没有tocken请求/user/whoami
    SpringBoot2.x简单使用JWT
    不带tocken发起请求的结果为:SpringBoot2.x简单使用JWT
    由于我对统一异常处理还不太熟悉,所以只是在拦截器的preHandle方法中对response进行了简单的信息填写。但是从message的值可以看出,拦截器发挥了作用。
    (2)再测试登录接口:/user/login,获取tockenSpringBoot2.x简单使用JWT
    登录请求的结果截图SpringBoot2.x简单使用JWT
    可以看到返回的数据中有最新的tocken,说明JWT工作也成功了。
    (3)再测试使用正确且未过期的tocken请求/user/whoami接口SpringBoot2.x简单使用JWT
    将登录后返回的额tocken复制过来放在/user/whoami请求的header中,并且设置其键名为tocken(也可命名为别的名字,但是拦截器中获取tocken所用的getHeader()方法中的参数值要保证与这里设定的键名一致)。请求的返回结果截图:SpringBoot2.x简单使用JWT
    说明tocken验证成功。
    (4)最后测试使用过期的tocken请求/user/whoami接口
    这里使用过期的tocken发起请求SpringBoot2.x简单使用JWT
    其返回结果为SpringBoot2.x简单使用JWT
  2. 遇到的问题及解决办法
    (1)由于我在配置注册自定义的拦截器时 ,一开始偷懒,这样注册:
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry
        .addInterceptor(new AuthenticationInterceptor())
        .addPathPatterns("/user/whoami");
    }
}

这就导致了自定义的拦截器中UserService一致导入不进来,百度了一下发现是由于不用@bean注解,是的拦截器没有添加进Spring的上下文之间,才会导致在拦截器中注入service时,报空指针异常。解决方法就是使用@bean注解提前将拦截器注册到Spring上下文中。
解决方法参考自:拦截器中无法注入service

  1. 可再进一步的地方
    (1)使用非对称**来签发tocken
    (2)还可结合shiro和redis使用
    (3)用https携带tocken
    (4)tocken加密加言(盐),具体哪个yan,我目前还不清楚。

  2. 项目源码
    GitHub:https://github.com/fanfanfufu/JWTlearning
    码云Gitee:https://gitee.com/fanfanfufu/JWTlearning

  3. 参考链接
    SpringBoot集成JWT实现token验证