前段时间考虑微信不能保存session的问题,尝试写了下利用token认证。JWT有现成库还是挺方便的,整理个demo出来。
后端用的Spring boot,因为有现成的数据库直接用mybatis来读用户信息。
主要流程可如下表示:
登录并请求token:
请求验证:
主要需要解释的就是返回的token以及refresh token
的区别。
token一般都会有个时效性,比如半小时失效。考虑只有单一token的情况,用户登陆后半小时事还没做完,可能当时填完一个表单,token失效,返回登录页面,那么用户的体验会非常差。
refresh token则为活跃用户提供了一个热更新token的方式,refresh token的时效设置比token长,比如一小时。那么假设用户一直活跃,并在31分时处理另一个事务,此时token过期,refresh token未过期,那么server判定用户活跃,直接给予新token的热更新,就能保证用户使用体验连续。
JWT,JSON WEB TOKEN,是一种常用的token生成标准。三部分组成,本项目中将用户的id信息存入token中,并将唯一私钥维护在服务器中,以利用token识别用户身份,不需要在服务器中存放额外用户信息。
JWT依赖:
<dependency>
<groupId>com.auth0groupId>
<artifactId>java-jwtartifactId>
<version>3.3.0version>
dependency>
JWTUtil.java
// JWT token生成工具类
@PropertySource("classpath:/application.yml")
@Component
public class JWTUtil {
private static long ALIVE_TIME;
private static final String SALT = "5oiR5Lmf5LiN5oeC77yM5q+V56uf5oiR5Y+q5piv5LiA5p2h54uX44CC";
@Value("${jwt.alive_time}")
public void setAliveTime(long aliveTime) {
ALIVE_TIME = aliveTime;
}
首先工具类包含两个变量,alive_time为token有效时间,利用@Value将它在配置文件中进行设置。SALT为唯一私钥,用来对token进行验证。
// 生成token,考虑到活跃用户过期体验,返回普通token和refresh_token的拼接
public static String createToken(String userId) throws JsonProcessingException, UnsupportedEncodingException {
// 用户id
String infoStr = userId;
// 设置JWT头,利用用户信息和盐生成结果
Date date = new Date(System.currentTimeMillis() + ALIVE_TIME);
Date refreshDate = new Date(System.currentTimeMillis() + 2 * ALIVE_TIME); // refresh token过期时间为两倍
Algorithm algorithm = Algorithm.HMAC256(SALT);
Map<String, Object> heads= new HashMap<>();
heads.put("typ", "JWT");
heads.put("alg", "HS256");
return JWT.create().withHeader(heads).withClaim("userId", infoStr).withExpiresAt(date).sign(algorithm) + ";" +
JWT.create().withHeader(heads).withClaim("userId", infoStr).withExpiresAt(refreshDate).sign(algorithm);
}
设置加密算法为HMACSHA256,JWT的output部分就是一个加密的JSON字符串,将用户id以userId为key来生成,最后一部分为私钥加密,生成token。返回的形式为token和refresh token并使用分号隔开。
// 验证单JWT token
public static boolean verifyToken(String token){
try {
Algorithm algorithm = Algorithm.HMAC256(SALT);
JWTVerifier verifier = JWT.require(algorithm).build();
verifier.verify(token);
return true;
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
return false;
} catch (JWTVerificationException e){
return false;
}
}
verifyToken()
对token进行验证,JWT库生成verifier验证类,当token无效或出错时抛出JWTVerificationException,通过捕捉该异常来判断token已过期。
// 返回解码id
public static String getDecodedId(String token){
DecodedJWT dJwt = JWT.decode(token);
String userId = dJwt.getClaim("userId").asString();
return userId;
}
demo也没多写个service啥的,直接获取id信息的方法耦合在工具类里了。
拦截器在token验证中很关键,在相应需要验证的请求前进行处理,对不同情况采取措施。
TokenAuthenticateInterceptor 拦截器类
public class TokenAuthenticateInterceptor implements HandlerInterceptor {
@Override
// 在这个方法中拦截请求,对token进行验证
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 跨域请求处理,设置头信息
response.addHeader("Access-Control-Allow-Origin", "*");
response.addHeader("Access-Control-Allow-Methods", "POST,OPTIONS,PUT,HEAD");
response.addHeader("Access-Control-Max-Age", "3600000");
response.addHeader("Access-Control-Allow-Credentials", "true");
response.addHeader("Access-Control-Allow-Headers", "token"); // 设置头部可携带token
// 禁止跨域缓存
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Cache-Control", "no-store");
response.setHeader("Pragma", "no-cache");
response.setDateHeader("Expires", 0);
// 浏览器预检
if (request.getMethod().equals("OPTIONS"))
response.setStatus(HttpServletResponse.SC_OK);
// 获取token,分割为token和refresh_token
// token未过期,直接放行;token过期,refresh_token未过期,生成新的token和refresh_token并放行;
// refresh_token过期,直接回登录页面
String tokens = request.getHeader("token");
if(tokens != null){
if(tokens.contains(";")){
String token = tokens.substring(0, tokens.indexOf(";"));
String refresh_token = tokens.substring(tokens.indexOf(";") + 1);
// 对token鉴定
if(JWTUtil.verifyToken(token)){
// 有效,放行
response.setHeader("token", tokens);
return true;
}else if(JWTUtil.verifyToken(refresh_token)){
// 有效,为活跃用户,更新token组合
String userId = JWTUtil.getDecodedId(refresh_token);
System.out.println(userId);
String newToken = JWTUtil.createToken(userId);
response.setHeader("token", newToken);
return true;
}else{
response.sendRedirect(request.getContextPath() + "/login");
}
}else{
response.sendRedirect(request.getContextPath() + "/login");
}
}else{
System.out.println(request.getContextPath());
response.sendRedirect(request.getContextPath() + "/login");
}
return false;
}
}
因为用的Springboot,所以拦截器类实现HandlerInterceptor
接口。
前段对response处理以及对OPTIONS请求的检测是为了token能进行跨域访问,这边就不讲了,主要功能就是允许跨域请求时头部可以带token字段,有空另开一篇文章讲讲各个头信息的作用。
和思路中说的一样,首先获取头部的token字段,并利用分号分解成token和refresh token,之后根据两个token是否有效来进行下一步的处理。
InterceptorConfig 拦截器配置类
@Configuration
// 设置拦截器作用范围
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
InterceptorRegistration registration = registry.addInterceptor(new TokenAuthenticateInterceptor());
registration.addPathPatterns("/user/userInfo");
}
}
demo没有好好划分,直接把唯一的获取用户具体信息的链接配置成需要进行拦截器处理。
@RequestMapping(value = "/checkLogin", method = RequestMethod.POST)
@ResponseBody
// 用户登录验证,返回token
public String userLoginCheck(@RequestParam("username") String userName, @RequestParam("password") String password, HttpServletRequest request){
// 检验登录
boolean isChecked = checkUser(userName, password);
if(isChecked){
// 获取用户id信息,作为JWT的payload,最终返回token
UserInfo info = userInfoMapper.getUserInfo(userName);
try {
// 包括refresh token
String token = JWTUtil.createToken(info.getUserId());
return token;
} catch (JsonProcessingException | UnsupportedEncodingException e) {
e.printStackTrace();
return null;
}
}
return null;
}
// 比对密码结果
private boolean checkUser(String username, String password){
UserLogin loginInfo = userLoginMapper.getUserLoginInfo(username);
if(loginInfo != null){
return loginInfo.getUserPassword().equals(password);
}
return false;
}
验证并获取token的请求,数据库中进行验证,成功则返回token字符串。
@RequestMapping(value = "/user/userInfo")
@ResponseBody
// 得到token中个人信息
public String getUserInfo(HttpServletRequest request){
// header中获取token信息
String tokens = request.getHeader("token");
String token = tokens.substring(0, tokens.indexOf(";"));
try {
return getDecodedUserInfo(token);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return null;
}
// 获取解码后的用户信息,这方法应该写到UserService里的但是咱没写这类,就先塞这里了
// 因为咱把所有用户id放在token里了,这边得处理下用户日期问题
// 太耦合了我要死了
public String getDecodedUserInfo(String token) throws UnsupportedEncodingException {
DecodedJWT dJwt = JWT.decode(token);
String userId = dJwt.getClaim("userId").asString();
UserInfo info = this.userInfoMapper.getUserInfo(userId);
ObjectMapper om = new ObjectMapper();
try {
String userStr = om.writeValueAsString(info);
Map<String, Object> maps = om.readValue(userStr, Map.class);
String bir = maps.get("userBirthday").toString();
Date date = new Date(Long.parseLong(bir));
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
String birStr = sdf.format(date);
maps.put("userBirthday", birStr);
return new ObjectMapper().writeValueAsString(maps);
} catch (JsonProcessingException e) {
e.printStackTrace();
return "";
}
}
demo中唯一经过拦截器验证的方法,获取用户信息。通过验证完正确有效的token进行用户信息JSON的获取。因为数据库里有个生日字段,做了下日期的格式转换。
前端请求全部使用AJAX进行控制,直接放js代码给大家看。
login.js
$(function(){
// 设置点击事件
$("#login").click(getToken)
});
// ajax获取token,返回为空则提示
function getToken(){
var username = $("#username").val()
var password = $("#password").val()
// ajax获取token信息
$.ajax({
type : "post",// 请求方式
url : "/checkLogin",// 发送请求地址
data: {
username: username,
password: password
},
dataType : "text",
async : false,
// data为token或空
success : function(data) {
console.log('data:' + data)
// 为空,出错
if(data == ""){
$("#tip").html("username or password wrong")
}else{
// 将token存入storage,并请求用户页面
localStorage.setItem("token", data)
window.location.href = "/userInformation"
}
},
error : function(){
alert("error")
}
});
}
用了JQuery。可以看到主要逻辑就是先带用户名和密码去请求/checkLogin,当返回正确的token组合后,存入localStorage中,并切换页面为用户信息页面。
userInformation.js
$(function(){
// ajax获取用户信息
$.ajax({
type : "post",// 请求方式
url : "/user/userInfo",// 发送请求地址
dataType : "json",
async : false,
// 将token加入头部
beforeSend : function(request){
var token = localStorage.getItem("token")
if(token == null){
window.location.href = "/login"
}else{
request.setRequestHeader("token", localStorage.getItem("token"))
}
},
// data为用户信息json
success : function(data) {
console.log("data:" + data)
if(data.userId != null){
$('#user_id').html(data.userId)
}
if(data.userName != null){
$('#user_name').html(data.userName)
}
if(data.userPhone != null){
$('#user_phone').html(data.userPhone)
}
if(data.userBirthday != null){
$('#user_birthday').html(data.userBirthday)
}
},
// 当response的头部有token信息时,进行token的无痛刷新
complete : function(xhr){
var token = xhr.getResponseHeader('token')
console.log("response token:" + token)
if(token != null){
localStorage.setItem("token", token)
}
},
error : function(){
window.location.href = "/login"
}
});
});
访问时提取localstorage中的token信息进行访问,经过拦截器验证后获得用户信息JSON,写到页面上。
可以看出,利用checklogin可正确获取token信息,存入localstorage中。在进行userInfo接口的访问请求时,headers中加入token信息,并能正常返回用户信息。
简单实现了下token验证的demo。token验证相比session来说,拓展性更强,并不需要使用cookie,这在分布式系统中更好用,不错。
整个demo的代码已传到github,有兴趣可以看一看。https://github.com/huiluczP/jwt_token