SpringBoot:基于JWT的token校验、单点登录、刷新token


前言


用户鉴权一直是我先前的一个问题,以前我用户接口鉴权是通过传入参数进行鉴权,只要是验证用户的地方就写token验证,虽然后面也把token验证方法提取到基类中,但是整体来说仍然不是太雅观,当时的接口如下所示.

    @RequestMapping(value = "like",method = RequestMethod.POST)
    public ResultMap userLikeOrDisLikeAction(@RequestParam(value = "shopId") String shopId,
                                             @RequestParam(value = "userId") String userId,
                                             @RequestParam(value = "islike") int islike,
                                             @RequestParam(value = "token") String token,
                                             @RequestParam(value = "timestamp") String timestamp
    )
    {

        ResultMap map = new ResultMap();

        if (!verifyTokenString(token,timestamp)){

            map.code = Constants.ERROR_CODE_TOKEN_NOT_EQUAL;
            map.msg = "token错误";
            return map;
        }

        ....

    }

反正一句话来说,自己太菜了...

其实很久之前,就有了相应的解决方案,那就是利用AOP在拦截器中统一处理token校验的问题,那我们一起看看SpringBoot中如何使用JWT来做Token校验和单点登录的.


JWT集成


项目是基于Maven来架构的,所以我们先导入JWT的依赖.整体如下所示.

        <!-- JWT的用户token相关 -->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.10.3</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.7.0</version>
        </dependency>
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>2.3.1</version>
        </dependency>
        <!-- JWT的用户token相关 -->

对于需要创建的类来说,主要有以下几个类.

下面我们简单看一下各个文件的作用.

InterceptorConfig : Spring boot2.0 官方推荐实现 WebMvcConfigurer 接口配置拦截器.
JwtConfig : token的相关方法工具类.
TokenInterceptor : 拦截器
PassTokenUserLoginToken : 自定义注解,用于标注接口或者类是否需要进行token验证.


具体代码


首先,我们对上面的类或者注解进行一个详细的说明.

InterceptorConfig

该类主要是用来配置拦截器的,具体代码如下所示.

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.annotation.Resource;

@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    @Resource
    private TokenInterceptor tokenInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(tokenInterceptor)
                .addPathPatterns("/**");
    }
}

JwtConfig

该类主要是用来定义token的相关方法.例如,创建token,创建刷新token等等,验证token是否过期,获取token中的用户信息等等.

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.springframework.stereotype.Component;

import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtConfig {
    private static final Log log = LogFactory.getLog(JwtConfig.class);

    private String secret = "秘钥,请自己定义";

    // 外部http请求中 header中 token的 键值
    private String header = "token";

    private static Map<String, String> tokenMap = new HashMap<>();

    /**
     * 生成token
     *
     * @param subject
     * @return
     */
    public String createToken(String subject) {
        Date nowDate = new Date();

        Calendar calendar = Calendar.getInstance();
        calendar.setTime(nowDate);
        calendar.add(Calendar.DAY_OF_MONTH, 10);
        Date expireDate = calendar.getTime();

        String userToken = Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject(subject)
                .setIssuedAt(nowDate)
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
        // 把token添加到缓存中
        tokenMap.put(subject, userToken);
        return userToken;
    }

    public String createRefreshToken(String subject) {
        Date nowDate = new Date();

        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject(subject)
                .setIssuedAt(nowDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    /**
     * 获取token中注册信息
     *
     * @param token
     * @return
     */
    public Claims getTokenClaim(String token) {
        try {
            return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * 验证token是否过期失效
     *
     * @param expirationTime
     * @return
     */
    public boolean isTokenExpired(Date expirationTime) {
        return expirationTime.before(new Date());
    }

    /**
     * 获取token失效时间
     *
     * @param token
     * @return
     */
    public Date getExpirationDateFromToken(String token) {
        return getTokenClaim(token).getExpiration();
    }

    /**
     * 获取用户名从token中
     */
    public String getUsernameFromToken(String token) {
        return getTokenClaim(token).getSubject();
    }

    /**
     * 获取jwt发布时间
     */
    public Date getIssuedAtDateFromToken(String token) {
        return getTokenClaim(token).getIssuedAt();
    }

    // --------------------- getter & setter ---------------------

    public String getSecret() {
        return secret;
    }

    public void setSecret(String secret) {
        this.secret = secret;
    }

    public String getHeader() {
        return header;
    }

    public void setHeader(String header) {
        this.header = header;
    }

    public Map<String, String> getTokenMap() {
        return tokenMap;
    }
}

PassToken

定义一个哪些类或者接口跳过验证的注解,不添加也也判定是跳过验证.具体实现代码如下所示.

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PassToken {
    boolean required() default true;
}

UserLoginToken

定义一个哪些类或者接口需要验证的注解,具体实现代码如下所示.


import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface UserLoginToken {
    boolean required() default true;
}

TokenInterceptor

拦截器,继承于 HandlerInterceptorAdapter 这个抽象类, 实现接口拦截验证功能,具体代码如下所示.

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.SignatureException;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.lang.reflect.Method;

@Component
public class TokenInterceptor extends HandlerInterceptorAdapter {

    @Resource
    private JwtConfig jwtConfig;

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws SignatureException, IOException {

        String uri = request.getRequestURI();
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        /** 检查是否有passtoken注释,有则跳过认证 */
        if (method.isAnnotationPresent(PassToken.class)) {
            PassToken passToken = method.getAnnotation(PassToken.class);
            if (passToken.required()) {
                return true;
            }
        }

        /** 检查有没有需要用户权限的注解 */
        if (method.isAnnotationPresent(UserLoginToken.class)) {
            /** Token 验证 */
            String token = request.getHeader(jwtConfig.getHeader());
            if (StringUtils.isEmpty(token)) {
                token = request.getParameter(jwtConfig.getHeader());
            }
            if (StringUtils.isEmpty(token)) {
                response.sendError(401, "token信息不能为空");
                return false;
            }
            String userName = jwtConfig.getUsernameFromToken(token);
            String compareToken = jwtConfig.getTokenMap().get(userName);
            if (compareToken != null && !compareToken.equals(token)) {
                response.sendError(400, "token已经失效,请重新登录");
                return false;
            }

            UserLoginToken userLoginToken = method.getAnnotation(UserLoginToken.class);
            if (userLoginToken.required()) {
                Claims claims = null;
                try {
                    claims = jwtConfig.getTokenClaim(token);
                    if (claims == null || jwtConfig.isTokenExpired(claims.getExpiration())) {
                        response.sendError(400, "token已经失效,请重新登录");
                        return false;
                    }
                } catch (Exception e) {
                    response.sendError(400, "token已经失效,请重新登录");
                    return false;
                }
                /** 设置 identityId 用户身份ID */
                request.setAttribute("identityId", claims.getSubject());
                return true;
            }
            if (compareToken == null) {
                // 由于服务器war重新上传导致临时数据丢失,需要重新存储
                jwtConfig.getTokenMap().put(userName, token);
            }
        }

        return true;
    }
}


Token验证


Token验证的过程主要是在拦截器中,用户在登录过程中,我们需要把生成好的token 、refreshToken(刷新token)、expirationDate(过期时间)发送给用户.然后再需要的接口的header中传入token信息用于验证.

验证过程主要是在 preHandle 方法中实现的.

首先我们验证是否含有 @PassToken 这个注解,如果有,那么直接跳过验证.

      if (method.isAnnotationPresent(PassToken.class)) {
            PassToken passToken = method.getAnnotation(PassToken.class);
            if (passToken.required()) {
                return true;
            }
        }

然后只有含有 @UserLoginToken 的接口中才去验证token.验证Token主要是验证它的过期时间.代码如下所示.

            if (userLoginToken.required()) {
                Claims claims = null;
                try {
                    claims = jwtConfig.getTokenClaim(token);
                    if (claims == null || jwtConfig.isTokenExpired(claims.getExpiration())) {
                        response.sendError(400, "token已经失效,请重新登录");
                        return false;
                    }
                } catch (Exception e) {
                    response.sendError(400, "token已经失效,请重新登录");
                    return false;
                }
                /** 设置 identityId 用户身份ID */
                request.setAttribute("identityId", claims.getSubject());
                return true;
            }


单点登录


如何简单实现一个单点登录呢?我们需要维护一个全局的HaspMap,以 Token中的 subject (这里我使用的不会重复的username) 作为键值,以token为value存储. Map定义在 JwtConfig 中,代码如下所示.

    private static Map<String, String> tokenMap = new HashMap<>();

在创建token的方法中,我们认定前面的token都失效了,所以我们直接添加即可,如果存在旧的token就进行覆盖操作,如果没有就进行添加.代码如下所示.

    public String createToken(String subject) {

        ....

        String userToken = ....

        tokenMap.put(subject, userToken);

        ....
    }

在拦截器中的拦截方法中我们需要去验证 传入的token是否是我们存储中的token,如果不是,那么就直接返回token过期.

    String userName = jwtConfig.getUsernameFromToken(token);
    String compareToken = jwtConfig.getTokenMap().get(userName);
    if (compareToken != null && !compareToken.equals(token)) {
        response.sendError(400, "token已经失效,请重新登录");
        return false;
    }

由于HashMap存储在缓存中,当下次服务重启的时候,HashMap所有值就会失效.这时候我们该如何做呢?我们需要在拦截方法最后把当前验证完毕的token 重新填入 Map中即可.

    if (compareToken == null) {
        // 由于服务器war重新上传导致临时数据丢失,需要重新存储
        jwtConfig.getTokenMap().put(userName, token);
    }


刷新token


当token过期之后,我们允许用户进行token的刷新.这时候我们需要定义一个生成刷新token的方法,如下所示.

    public String createRefreshToken(String subject) {
        Date nowDate = new Date();

        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject(subject)
                .setIssuedAt(nowDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

我们已经在登录之时把该refreshToken 返回给用户,只要我们定义接口实现新token的创建即可.这样就完成token的刷新了.


结语


基于JWT的token校验、单点登录、刷新token整体来说还是比较简单的,如果有问题,欢迎各位大佬在评论区指导批评,谢谢啦~OK,今天就到这里了.....


©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345