动手写个java快速开发框架-(5)实现统一用户token校验

访问权限校验是一个互联网系统必备的功能,今天我们就动手将访问权限控制的功能加到框架中,可以针对特定路径的接口进行访问权限校验,防止出现越权访问的情况发生。

以前我们做权限控制都是通过用户登陆后由服务端生成sessionid,并将这个sessionid与用户信息关联起来,客户端需要保存sessionid在cookie中,在后续请求交易中上送这个cookie,服务端从header中获取到cookie,并从cookie中获取到sessionid,来判断该sessionid是否还有效。在分布式、前后端分离还没那么火的时候,权限控制基本都是基于session来实现的,这么做最大的缺点是,客户端需要每次都访问到固定的服务器节点才能查询到sessionid,不然会导致查询不到重新登陆的问题,这就需要在负载均衡F5或者nginx上配置根据sessionid做hash路由分发,一旦某台机器出问题需要通过这台服务节点登陆的所有用户全部重新登陆,影响还是很大的。随着分布式架构的快速发展,移动互联的快速发展也推动了前后端分离的快速发展,最近几年新建系统基本都是前后端分离分布式架构了,这样的架构自然不再适合用session这种机制了,oauth2、分布式token等等授权概念纷纷推出。一般有一定规模用户的公司会使用oauth这样的认证授权的架构,对于不了解oauth协议的同学建议先看看阮一峰的这篇文章,写的非常浅显易懂,原先笔者在几家大公司用的也是oauth,但是用过oauth的同学应该也都知道,oauth虽然能够在开放的前提下保证安全,但是还是有点重,除非是对于要建设开放平台的系统一定要用oauth,对于一般的互联网项目,其实是没必要使用oauth的,oauth同样是一种数据集中授权的模式,那么势必还是要想办法保证oauth token集中校验的性能和高可用,不管是从开发还是从硬件投入上都是一笔不小的开支。

所以既然要做一款轻量级的快速开发框架,那么这里我们使用了更加轻量级的token发放和验证方案-jwt https://jwt.io,其实简单理解就是jwt按照一定的规则生成了一个散列值作为token,类似下图左边是jwt编码后的token,右边是原始明文组成要素主要有3部分,第一部分定义散列算法和生成类型,第二部分payload是用户可以自定义一些用户标示要素项,内部其实是使用对称秘钥进行了加密这部分数据,第三部分是校验域用来给服务端校验有效性用的:

Screenshot 2018-09-12 10.34.16

并且可以通过该token还原出用户的标示信息,同时提供了token校验的功能,并且不需要集中验证,任何一台服务节点都可以根据规则进行校验,只要保证每台机器生成token的秘钥保持一致就可以。jwt是非常轻量级的一种方案,对于开发一般的项目作为权限校验足够了。

下面我们再来看看客户端和服务端的交互流程:

client->server: 登陆
server-->client: jwt生成并返回token
client->server: 携带token请求接口
server->server: 通过jwt校验token有效性
server-->client: 返回接口内容

整个流程是不是很清晰,同时jwt提供了一系列的接口包含了,生成token、注销token、有效期设置等等常用的功能。下面我们利用jwt来实现用户token的生成和校验功能,这里我们通过全局拦截器来实现token的校验,这样可以做到对业务功能的无感。

首先我们要定义用户的注册和登录的相关操作,文章中就不详细讲解了,代码很简单,具体大家可以参考源码的modules.module1模块中的相关业务代码。

接下来就是我们具体集成jwt了。

pom依赖

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>${jwt.version}</version>
</dependency>

jwt二次封装

这里我们对jwt做了简单的封装,主要封装了token生成方法,我们这里的payload只有userid,大家可以可以根据自己项目需要定制payload,这个payload在后续会使用到,用来从线程中获取userid来判断用户权限。还封装了获取token的Claims对象的方法,Claims大家可以理解为jwt明文的报文体结构,在验证的时候可以通过获取到当前请求的用户payload信息。

@ConfigurationProperties(prefix = "mk.jwt")
@Component
public class JwtUtils {
    private Logger logger = LoggerFactory.getLogger(getClass());

    private String secret;
    private long expire;
    private String header;

    /**
     * 生成jwt token
     */
    public String generateToken(long userId) {
        Date nowDate = new Date();
        //过期时间
        Date expireDate = new Date(nowDate.getTime() + expire * 1000);

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

    public Claims getClaimByToken(String token) {
        try {
            return Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(token)
                    .getBody();
        }catch (Exception e){
            logger.debug("validate is token error ", e);
            return null;
        }
    }

    /**
     * token是否过期
     * @return  true:过期
     */
    public boolean isTokenExpired(Date expiration) {
        return expiration.before(new Date());
    }

    //...省略setter、getter
}

定义拦截器

最后就是定义拦截器了,让自定义拦截器继承自HandlerInterceptorAdapter这个拦截器适配器类,当然你也可以直接实现HandlerInterceptor接口,使用适配器类的话只需要实现自己关心的preHandle还是postHandle就可以了,这里我们只需要实现prehandle,在controller和servlet处理之前拦截就可以了。

代码注释比较完善,大家看注释就可以了。

@Component
public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
    @Autowired
    private JwtUtils jwtUtils;

    public static final String USER_KEY = "userId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        Login annotation;
        if(handler instanceof HandlerMethod) {
            annotation = ((HandlerMethod) handler).getMethodAnnotation(Login.class);
        }else{
            return true;
        }

        if(annotation == null){
            return true;
        }

        //获取用户凭证
        String token = request.getHeader(jwtUtils.getHeader());
        if(StringUtils.isBlank(token)){
            token = request.getParameter(jwtUtils.getHeader());
        }

        //凭证为空
        if(StringUtils.isBlank(token)){
            throw new MkException(jwtUtils.getHeader() + "不能为空", HttpStatus.UNAUTHORIZED.toString());
        }

        Claims claims = jwtUtils.getClaimByToken(token);
        if(claims == null || jwtUtils.isTokenExpired(claims.getExpiration())){
            throw new MkException(jwtUtils.getHeader() + "失效,请重新登录", HttpStatus.UNAUTHORIZED.toString());
        }

        //设置userId到request里,后续根据userId,获取用户信息
        request.setAttribute(USER_KEY, Long.parseLong(claims.getSubject()));

        return true;
    }
}

定义好了拦截器,我们还需要在启动的时候注册上拦截器,我们还是在之前定义WebMvcConfig中进行注册拦截器,如果对指定的path进行拦截,那么也可以在这里配置。

@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {

    @Autowired
    private DesParamsHandlerMethodArgumentResolver desParamsHandlerMethodArgumentResolver;
    @Autowired
   private AuthorizationInterceptor authorizationInterceptor;

   @Override
   public void addInterceptors(InterceptorRegistry registry) {
      registry.addInterceptor(authorizationInterceptor).addPathPatterns("/**");
   }
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(desParamsHandlerMethodArgumentResolver);
    }
}

验证

验证就比较简单了,注册、登录的controller方法都在TestUserController中,大家可以先访问register注册一个用户,再调用login获取到该用户的token,再将这个token放到queryUser接口的header中去访问就可以获取到该用户的信息了,如果在header中没有token或者token错误都会报错,并且也获取不到别人的信息,只能获取到这个token的用户信息。

总结

本框架中用了jwt来实现token校验,当然大家也可以通过使用oauth来实现token的相关功能,如果实现oauth的相关功能,大家可以使哟过spring security或者shiro,token数据可以缓存在redis集群里来保证性能和高可用。

本文对应的github tag为v0.5,可以通过连接下载https://github.com/feiweiwei/MkFramework4java/releases/tag/v0.5,也可以通过git clone -b v0.5 https://github.com/feiweiwei/MkFramework4java.git

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

推荐阅读更多精彩内容