基于JWT实现的token认证尝试

前段时间考虑微信不能保存session的问题,尝试写了下利用token认证。JWT有现成库还是挺方便的,整理个demo出来。

项目简介

后端用的Spring boot,因为有现成的数据库直接用mybatis来读用户信息。

思路

主要流程可如下表示:
登录并请求token:

  1. client登录,向server请求token
  2. server验证登录信息,生成JWT token和refresh token。
  3. client接受token,存入localstorage

请求验证:

  1. client将token放入请求头headers并发送
  2. server将请求在对应拦截器中进行处理,检查token是否过期
  3. 三种情况:token过期,返回登陆页面;token未过期,正常请求继续;token过期,refresh token未过期,生成新token与refresh token并放入response的header中,之后正常回应。
  4. client接收正常回应,并检查响应头中是否有token信息,有则更新local storage中信息。

token与refresh token

主要需要解释的就是返回的token以及refresh token的区别。

token一般都会有个时效性,比如半小时失效。考虑只有单一token的情况,用户登陆后半小时事还没做完,可能当时填完一个表单,token失效,返回登录页面,那么用户的体验会非常差。

refresh token则为活跃用户提供了一个热更新token的方式,refresh token的时效设置比token长,比如一小时。那么假设用户一直活跃,并在31分时处理另一个事务,此时token过期,refresh token未过期,那么server判定用户活跃,直接给予新token的热更新,就能保证用户使用体验连续。

JWT

JWT,JSON WEB TOKEN,是一种常用的token生成标准。三部分组成,本项目中将用户的id信息存入token中,并将唯一私钥维护在服务器中,以利用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没有好好划分,直接把唯一的获取用户具体信息的链接配置成需要进行拦截器处理。

控制器controller

    @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的获取。因为数据库里有个生日字段,做了下日期的格式转换。

js

前端请求全部使用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,写到页面上。

效果展示

基于JWT实现的token认证尝试_第1张图片
基于JWT实现的token认证尝试_第2张图片
基于JWT实现的token认证尝试_第3张图片

可以看出,利用checklogin可正确获取token信息,存入localstorage中。在进行userInfo接口的访问请求时,headers中加入token信息,并能正常返回用户信息。

总结

简单实现了下token验证的demo。token验证相比session来说,拓展性更强,并不需要使用cookie,这在分布式系统中更好用,不错。
整个demo的代码已传到github,有兴趣可以看一看。https://github.com/huiluczP/jwt_token

你可能感兴趣的:(java,jwt,spring,boot,web,javascript,java)