接上篇博客,继续讲解JWT的使用。
本部分内容主要是SpringBoot项目中具体使用JWT的代码实现,包括了登录创建Token,后续请求验证Token,并且加入了JWT刷新机制等等。
本文大纲:
1.JWT的引入
2.JWT工具类的实现
3.登录申请Token
4.请求验证Token
5.JWT刷新机制的实现
注意:本文注重使大家理解JWT如何在SpringBoot项目中集成和使用,包含了我项目中JWT使用的90%的代码,剩下10%的代码比如代码中导包的信息,Dao持久层的代码等不包含在本文章中,因此还需要自己根据自己项目的情况去集成本文的代码。
==========================================开始分割线===============================================
项目中引入Jar包依赖
com.auth0
java-jwt
3.4.0
直接上工具类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项目中具体使用的方法
@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;
}
}
/**
* 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)。
请求验证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));
}
}
/**
* 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的机制。
由于JWT是默认不加密的,里面也可能存放一些隐私的数据,一旦发生泄漏,任何人都可以获得该令牌的所有权限,并且JWT存放在客户端管理,服务器没有控制,无法在使用过程中废弃某个Token。
因此为了减少盗用,JWT 的有效期应该设置得比较短。但是,设置的短,比如说60分钟,我们每过60分钟就要重新进行登录,这样的用户体验就不太友好了。因此在安全性和用户体验性上面进行权衡,就出现了刷新机制。
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,才想写篇博客记录一下使用技巧的,以上两部分的文章,有什么问题尽管在下面回复留言,我会第一时间回复。