访问权限校验是一个互联网系统必备的功能,今天我们就动手将访问权限控制的功能加到框架中,可以针对特定路径的接口进行访问权限校验,防止出现越权访问的情况发生。
以前我们做权限控制都是通过用户登陆后由服务端生成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是用户可以自定义一些用户标示要素项,内部其实是使用对称秘钥进行了加密这部分数据,第三部分是校验域用来给服务端校验有效性用的:
并且可以通过该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