SpringBoot+JWT登录校验,以及JWT刷新机制

接上篇博客,继续讲解JWT的使用。

本部分内容主要是SpringBoot项目中具体使用JWT的代码实现,包括了登录创建Token,后续请求验证Token,并且加入了JWT刷新机制等等。

本文大纲:

1.JWT的引入

2.JWT工具类的实现

3.登录申请Token

4.请求验证Token

5.JWT刷新机制的实现

注意:本文注重使大家理解JWT如何在SpringBoot项目中集成和使用,包含了我项目中JWT使用的90%的代码,剩下10%的代码比如代码中导包的信息,Dao持久层的代码等不包含在本文章中,因此还需要自己根据自己项目的情况去集成本文的代码。

==========================================开始分割线===============================================

1、JWT的引入

项目中引入Jar包依赖


    com.auth0
    java-jwt
    3.4.0

2、JWT工具类的实现

直接上工具类WebTokenUtil.java的源码,包含创建Token和校验,刷新机制等一系列操作:

public class WebTokenUtil {
    private static final Logger logger = LoggerFactory.getLogger(WebTokenUtil.class);
    //定义JWT的发布者,这里可以起项目的拥有者
    private static final String TOKEN_ISSUSER = "issue";
    //定义JWT的有效时长
    private static final int TOKEN_VAILDITY_TIME = 30; // 有效时间(分钟)
    //定义允许刷新JWT的有效时长(在这个时间范围内,用户的JWT过期了,不需要重新登录,后台会给一个新的JWT给前端,这个叫Token的刷新机制后面会着重介绍它的意义。)
    private static final int ALLOW_EXPIRES_TIME = 60*24; //  允许过期时间(分钟)
    /**
     * 根据用户的登录时间生成动态私钥
     * @param instant 用户的登录时间,也就是申请令牌的时间
     * @return
     */
    public static String genSecretKey(Instant instant){
        return String.valueOf(instant.getEpochSecond());
    }
    public static String create(String secretKey, String subject, Instant issueAt) {
        return create(secretKey, subject, issueAt, TOKEN_VAILDITY_TIME);
    }
    /**
     * 生成token
     * @param secretKey 根据用户的登录时间生成的秘钥
     * @param subject JWT中payload部分自定义的内容
     * @param issueAt 用户登录的时间,也就是申请令牌的时间
     * @param validityTime 有效时长(分钟)
     * @return
     */
    public static String create(String secretKey, String subject, Instant issueAt, int validityTime) {
        String token = "";
        Algorithm algorithm = null;
        try {
            algorithm = Algorithm.HMAC256(secretKey);
        } catch (IllegalArgumentException | UnsupportedEncodingException e) {
            e.printStackTrace();
        }

        Instant exp = issueAt.plusSeconds(60*validityTime);
        token = JWT.create()
                .withIssuer(TOKEN_ISSUSER)
                .withClaim("sub", subject)
                .withClaim("iat", Date.from(issueAt))
                .withClaim("exp", Date.from(exp))
                .sign(algorithm);
        logger.trace("create token ["+token+"]; ");
        return token;
    }

    /**
     * 字符串token 解析为 jwtToken
     * @param token 要解析的Token
     * @return
     */
    public static DecodedJWT decode(String token){
        DecodedJWT jwtToken = null;
        try {
            jwtToken = JWT.decode(token);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return jwtToken;
    }

    /**
     * 验证token
     * @param secretKey
     * @param token
     * @throws Exception
     */
    public static void verify(String secretKey, String token) throws Exception {
        logger.debug("verify token ["+token+"]");
        Algorithm algorithm = null;
        try {
            algorithm = Algorithm.HMAC256(secretKey);
        } catch (IllegalArgumentException | UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        //校验Token
        JWTVerifier verifier = JWT.require(algorithm).withIssuer(TOKEN_ISSUSER).build();
        verifier.verify(token);
    }

    //刷新Token
    public static String getRefreshToken(String secretKey, DecodedJWT jwtToken) {
        return getRefreshToken(secretKey, jwtToken, TOKEN_VAILDITY_TIME);
    }
    //重载的刷新Token
    public static String getRefreshToken(String secretKey, DecodedJWT jwtToken, int validityTime) {
        return getRefreshToken(secretKey, jwtToken, validityTime, ALLOW_EXPIRES_TIME);
    }

    /**
     * 根据要过期的token获取新token
     * @param secretKey 根据用户上次登录时的时间,生成的密钥
     * @param jwtToken 上次的JWT经过解析后的对象
     * @param validityTime 有效时间
     * @param allowExpiresTime 允许过期的时间
     * @return
     */
    public static String getRefreshToken(String secretKey, DecodedJWT jwtToken, int validityTime, int allowExpiresTime) {
        Instant now = Instant.now();
        Instant exp = jwtToken.getExpiresAt().toInstant();
        //如果当前时间减去JWT过期时间,大于可以重新申请JWT的时间,说明不可以重新申请了,就得重新登录了,此时返回null,否则就是可以重新申请,开始在后台重新生成新的JWT。
        if ((now.getEpochSecond()-exp.getEpochSecond())>allowExpiresTime*60) {
            return null;
        }
        Algorithm algorithm = null;
        try {
            algorithm = Algorithm.HMAC256(secretKey);
        } catch (IllegalArgumentException | UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        //在原有的JWT的过期时间的基础上,加上这次的有效时间,得到新的JWT的过期时间
        Instant newExp = exp.plusSeconds(60*validityTime);
        //创建JWT
        String token = JWT.create()
                .withIssuer(TOKEN_ISSUSER)
                .withClaim("sub", jwtToken.getSubject())
                .withClaim("iat", Date.from(exp))
                .withClaim("exp", Date.from(newExp))
                .sign(algorithm);
        logger.trace("create refresh token ["+token+"]; iat: "+Date.from(exp)+" exp: "+Date.from(newExp));
        return token;
    }
}

工具类中主要包含了创建Token的create()方法,校验Token的verify()方法,以及获取刷新Token的getRefreshToken()方法等,这三个方法足够可以实现JWT的功能了:跨域认证。

下面是SpringBoot项目中具体使用的方法

3、登录申请Token

  • Controller层做登录验证密码,调用创建Token
@RestController
public class AuthController {
    @Autowired
    UserService userService;
    /**
     * 用户登录
     * @param loginForm
     */
    @PostMapping("/login")
    public ResponseEntity login(@RequestBody Map loginForm) throws BusiException {
        // 1. 获取登录表单
        String userName = loginForm.get("username");
        String plaintextPassword = loginForm.get("password");
        // 2. 获取用户
        User user = null;
        if (userName.contains("@")) {
            user = userService.getByEmail(userName);
        } else {
            user = userService.getByUserName(userName);
        }
        if (user==null) {
            throw new BusiException("账号或密码错误");
        }
        // 3. 检查密码
        boolean checkPassword = PasswordHelper.validate(user.getPassword(), plaintextPassword, user.getSalt());
        if (!checkPassword) {
            throw new BusiException("账号或密码错误");
        }
        // 4. 生成Token,拿到Token设置在响应Headers里返回
        String token = userService.createWebToken(user);
        HttpHeaders headers = new HttpHeaders();
        headers.set("Set-Token", token);
        RestResult result = RestResult.succ("登录成功", userService.getById(user.getId()));
        ResponseEntity resp = new ResponseEntity<>(result, headers, HttpStatus.OK);
        return resp;
    }

}
  • Service层做服务的处理以及调用工具类中的方法生成Token
/**
 * UserService
 */
public interface UserService {
    User getById(Integer id);
    User getByUserName(String userName);
    User getByEmail(String email);
    String createWebToken(User user);
}
/**
 * UserServiceImpl
 */
@Service
public class UserServiceImpl implements UserService {
    @Autowired
    UserDao userDao;
    @Override
    public User getById(Integer id) {
        return userDao.selectByPrimaryKey(id);
    }
    @Override
    public User getByUserName(String userName) {
        return userDao.selectByUserName(userName);
    }
    @Override
    public User getByEmail(String email) {
        return userDao.selectByEmail(email);
    }
    //创建Token,这里要根据当前时间获取密钥,并且生成Token,更新用户的最后登入时间
    @Transactional
    @Override
    public String createWebToken(User user) {
        Instant now = Instant.now();
        String secretKey = WebTokenUtil.genSecretKey(now);
        String token = WebTokenUtil.create(secretKey, user.getId().toString(), now, 5);
        user.setLastLogin(LocalDateTime.ofInstant(now, ZoneId.systemDefault()));
        userDao.updateByPrimaryKey(user);
        return token;
    }
}

具体Dao层的实现就不贴了,就是做数据持久化处理。

以上是第一次登录,生成Token的代码实现。那么生成完了Token返回给了客户端,客户端登录成功,以后的请求就要求客户端在请求Headers里面携带这个Token了。第一部分中说到,客户端可以放在Headers里的Authorization字段中,让服务器去读取和校验。需要的同学可以去看一下第一部分(第一部分直通车:https://blog.csdn.net/qq_38345296/article/details/99555273)。

4、请求验证Token

请求验证Token主要分为两部分:跨域设置+拦截器拦截请求验证Token

  • 跨域设置

SpringBoot2.0之后,使用WebMvcConfigurer这个接口来定义跨域,拦截器等信息

/**
 * WebConfig 自定义配置类
 */
@Configuration
public class WebConfig {
    @Bean
    public WebMvcConfigurer webMvcConfigurer() {
        return new WebMvcConfigurer() {
            //重写这个方法,添加跨域设置
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                //定义哪些URL接受跨域
                registry.addMapping("/**")
                        //定义哪些origin可以跨域请求
                        .allowedOrigins("*")
                        //定义接受的跨域请求方法
                        .allowedMethods("POST", "GET", "PUT", "PATCH", "OPTIONS", "DELETE")
                        .exposedHeaders("Set-Token")
                        .allowCredentials(true)
                        .allowedHeaders("*")
                        .maxAge(3600);
            }
            //注册拦截器
            @Override
            public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userSecurityHandlerInterceptor()).addPathPatterns("/**").excludePathPatterns("/login", "/dev/**");
            }
        };
    }
    //定义拦截器,UserSecurityHandlerInterceptor这个类实现了HandlerInterceptor接口
    @Bean
    public UserSecurityHandlerInterceptor userSecurityHandlerInterceptor() {
        return new UserSecurityHandlerInterceptor();
    }

}
  • 定义拦截器 
/**
 * UserSecurityHandlerInterceptor 自定义拦截器类
 */
public class UserSecurityHandlerInterceptor implements HandlerInterceptor {
    @Autowired
    private UserSecurityUtil userSecurityUtil;
    /**
     * 进行token验证和权限验证
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断是否是跨域请求,并且是options的请求,直接返回true
        if (request.getHeader(HttpHeaders.ORIGIN) != null & HttpMethod.OPTIONS.matches(request.getMethod())) {
            return true;
        }
        System.err.println("UserSecurityHandlerInterceptor preHandle ...");
        boolean check = true;
        //校验的方法封装在了UserSecurityUtil这个类中,后面有这个类的代码
        check = userSecurityUtil.verifyWebToken(request, response);

        if (!check) {
            writeResponse(response, HttpStatus.UNAUTHORIZED, RestResult.fail("请重新登录"));
            return false;
        }
        return check;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    }

    private void writeResponse(HttpServletResponse resp, HttpStatus status, RestResult restResult) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        resp.setStatus(status.value());
        resp.setContentType("application/json; charset=utf-8");
        resp.getWriter().write(objectMapper.writeValueAsString(restResult));
    }
}
  • 安全校验工具类,处理校验Token的细节步骤
/**
 * UserSecurityUtil 用户安全工具类
 */
@Service
public class UserSecurityUtil {
    private static final Logger logger = LoggerFactory.getLogger(UserSecurityUtil.class);
    @Autowired
    UserDao userDao;
    /**
     * 验证请求中的token
     * @return
     */
    public boolean verifyWebToken(HttpServletRequest req, HttpServletResponse resp) {
        String token = req.getHeader("Authorization");
        if (StringUtils.isEmpty(token)) {
            return false;
        }
        DecodedJWT jwtToken = WebTokenUtil.decode(token);
        if (jwtToken == null) {
            return false;
        }
        //从JWT里取出存放在payload段里的userid,查询这个用户信息得到用户最后登录时间
        Integer userId = Integer.valueOf(jwtToken.getSubject());
        User user = userDao.selectByPrimaryKey(userId);
        LocalDateTime lastLogin = user.getLastLogin();
        //根据用户登录时间,拿到用户申请Token时的secretKey
        String secretKey = WebTokenUtil.genSecretKey(lastLogin.atZone(ZoneId.systemDefault()).toInstant());

        try {
            //校验
            WebTokenUtil.verify(secretKey, token);
        } catch (SignatureVerificationException e) {
            logger.error(e.getMessage());
            return false;
        } catch (TokenExpiredException e) {
            // 允许一段时间有效时间同时返回新的token
            String newToken = WebTokenUtil.getRefreshToken(secretKey, jwtToken);
            if (StringUtils.isEmpty(newToken)) {
                logger.error(e.getMessage());
                return false;
            }
            logger.debug("Subject : [" + userId + "] token expired, allow get refresh token [" + newToken + "]");
            resp.setHeader("Set-Token", newToken);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }
}

这个类中,拿到了拦截器传递过来的HttpServletRequest,取出请求头中的Token,进行Decode和校验,并返回给拦截器校验的结果,拦截器返回true,请求顺利进入Controller执行用户的请求。到这里相信都可以看明白,

注意,上面代码中,当Token过期了,会抛出TokenExpiredException,这里做了异常捕获,去调用刷新Token接口,这个就是刷新Token的机制,下面讲解刷新Token的机制。

5、JWT刷新机制的实现

由于JWT是默认不加密的,里面也可能存放一些隐私的数据,一旦发生泄漏,任何人都可以获得该令牌的所有权限,并且JWT存放在客户端管理,服务器没有控制,无法在使用过程中废弃某个Token。

因此为了减少盗用,JWT 的有效期应该设置得比较短。但是,设置的短,比如说60分钟,我们每过60分钟就要重新进行登录,这样的用户体验就不太友好了。因此在安全性和用户体验性上面进行权衡,就出现了刷新机制。

  • 刷新机制的原理:服务器保存两个字段,一个是Token有效时间,另一个叫做允许过期时间。

Token有效时间这个就不用说了,我们在创建JWT的时候已经在内部封装了有效时间。那什么叫做允许过期时间呢?

允许过期时间是指允许在Token过期后的这段时间里,用户请求过来,不跳转到登录页面,而是给用户在后台重新申请一个新的Token,返回给用户,用户下次请求的时候,带着新的Token过来。这样做既实现了Token的更换,整个过程又对用户是隐藏的,保证了用户的体验性。

搞明白了为什么要使用刷新机制,那么就来看看代码,重新获取Token的代码也在上面的WebTokenUtil.java工具类里面,上面是完整的工具类,这里我只把那个方法再粘过来方便查看讲解。

/**
     * 根据要过期的token获取新token
     * @param secretKey 根据用户上次登录时的时间,生成的密钥
     * @param jwtToken 上次的JWT经过解析后的对象,其实就是把JWT的Base64解码了
     * @param validityTime 有效时间
     * @param allowExpiresTime 允许过期的时间
     * @return
     */
    public static String getRefreshToken(String secretKey, DecodedJWT jwtToken, int validityTime, int allowExpiresTime) {
        Instant now = Instant.now();
        Instant exp = jwtToken.getExpiresAt().toInstant();
        //如果当前时间减去JWT过期的时间,大于允许过期时间,说明不允许重新申请了,就得重新登录了,此时返回null,否则就是可以重新申请,开始在后台重新生成新的JWT。
        if ((now.getEpochSecond()-exp.getEpochSecond())>allowExpiresTime*60) {
            return null;
        }
        Algorithm algorithm = null;
        try {
            algorithm = Algorithm.HMAC256(secretKey);
        } catch (IllegalArgumentException | UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        //在原有的JWT的过期时间的基础上,加上这次的有效时间,得到新的JWT的过期时间
        Instant newExp = exp.plusSeconds(60*validityTime);
        //创建JWT
        String token = JWT.create()
                .withIssuer(TOKEN_ISSUSER)
                .withClaim("sub", jwtToken.getSubject())
                .withClaim("iat", Date.from(exp))
                .withClaim("exp", Date.from(newExp))
                .sign(algorithm);
        logger.trace("create refresh token ["+token+"]; iat: "+Date.from(exp)+" exp: "+Date.from(newExp));
        return token;
    }

这里有个注意事项需要考虑,会不会出现这种情况:用户在Token过期之后,允许过期时间内请求,返回的新的Token用户并没有使用,而是继续用老的Token去请求,要知道这个时候,后台还会去给他申请Token,并且也是可以请求到接口的,一直用老的Token去获取新的Token,这样的话,我们要保证用户在这个时间段,申请的新的Token都是同一个,所以,刷新Token的方法中,要将用户最后一次登录的时间,作为签名加密算法的密钥。

 

==========================================结束分割线===============================================

 

好了,到这里,SpringBoot项目集成JWT实现就已经全部介绍完了。鄙人也是最近在用JWT,才想写篇博客记录一下使用技巧的,以上两部分的文章,有什么问题尽管在下面回复留言,我会第一时间回复。

你可能感兴趣的:(Java开源框架学习)