久未写博客,文字略有生疏。这篇主要是关于分布式集群架构下,无状态应用服务器基于Shiro与Jwt的集成解决业务与数据权限治理的技术方案。本文不涉及任何权限数据模型。
在叙述之前,首先先来了解下何为无状态应用服务器,一般来说Web应用或多或少都会涉及到状态这个概念,何为状态?通俗点说,即对服务器的访问产生了留存数据。客户端的访问行为依赖于这些数据,且这些数据会随着请求的动作而产生变化,如Java当中的HttpSession,此即为状态。
我们先看下常规的应用服务器集群架构,如下图:
架构图.png
假设我们的应用服务器是存在状态的,使用HttpSession或者其他。此刻由于访问量激增需要弹性扩容,那么我们是否可以保证客户端的访问不受影响呢?譬如:保持登录状。客观的说这不难,一些硬件级别的设备如F5或软负载Nginx都提供了会话保持的粘滞策略,但都有其局限性。就举一个场景来表述下这个局限性:路由的目标服务器扩容期间崩溃了,那所有的状态数据就都丢失了,显然无法满足高可用特性。那我们再看看无状态服务器,其不存储任何状态信息。所有的集群节点下任意一台服务器都是一样的,任何一台宕机都不影响服务的可用性,完全满足高可用性的设计指标。
于此,一些基础知识的普及已经完成,有想更深入了解的同学推荐一本书<大型网站技术架构>。再回过头看我们需要解决的问题:1.无状态下的业务与数据权限治理,安全这块侧重于解决token防窃持。 我们采用的技术栈是SpringBoot + Shiro + Jwt, 这个技术栈当中Shrio的权限治理默认是基于用户会话的与HttpSession类似。显然这个默认行为违背了我们设计无状态应用服务器的初衷。我们需要加以改造,核心代码如下:
@Bean(value = "securityManager")
public SecurityManager securityManager(@Qualifier(value = "userRealm") UserRealm userRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(userRealm);
// 无状态应用服务器禁止session创建
DefaultSubjectDAO subjectDao = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator webSubjectDao = (DefaultSessionStorageEvaluator) subjectDao
.getSessionStorageEvaluator();
webSubjectDao.setSessionStorageEnabled(false);
securityManager.setSubjectDAO(subjectDao);
securityManager.setSubjectFactory(new StatelessDefaultSubjectFactory());
DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager();
defaultWebSessionManager.setSessionValidationSchedulerEnabled(false);
securityManager.setSessionManager(defaultWebSessionManager);
return securityManager;
}
完成了这块的改造后,Shiro也就完成了无状态的改造。
那如何与Jwt集成呢?首先我们需要了解什么是Jwt,根据百度百科描述:Jwt是轻量级的客户端与服务器认证通信解决方案。Jwt数值存储于客户端,介质可以是cookie/localStorage或者其它,每次请求时需要将Jwt签名数值传递至服务器, 服务器主要完成对Jwt的验签即可完成对访问请求的准确性认证。部分集成代码如下:
/**
* jwt 拦截具体动作
*
* @author gewx
**/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
// 从请求头或者URL当中获取token
String token = ObjectUtils.defaultIfNull(req.getHeader(AUTH_TOKEN), req.getParameter(AUTH_TOKEN));
if (StringUtils.isBlank(token)) {
String responseJson = JSONObject
.toJSONString(Response.FAIL.newBuilder().addGateWayCode(GateWayCode.E0001).toResult());
outFail(resp, responseJson);
return false;
}
try {
try {
//本地鉴权/远程鉴权
boolean bool = JwtUtils.verifyToken(token);
if (!bool) {
String responseJson = JSONObject.toJSONString(
Response.FAIL.newBuilder().addGateWayCode(GateWayCode.E0002).out("鉴权失败~").toResult());
outFail(resp, responseJson);
return false;
}
} finally {
/**
* create new token 无论认证通过与否,token必须具备一次消费属性
**/
Jwt.JwtBean bean = JwtUtils.parseToken(token);
JwtToken jwtToken = new JwtToken(
Jwt.create().setUserName(bean.getUserName()).setExpires(30).build().sign());
getSubject(request, response).login(jwtToken);
resp.setHeader(AUTH_TOKEN, jwtToken.getToken());
}
} catch (Exception ex) {
String responseJson = JSONObject.toJSONString(
Response.FAIL.newBuilder().addGateWayCode(GateWayCode.E9999).out("token 认证失败~").toResult());
outFail(resp, responseJson);
return false;
}
return true;
}
基于此,Shrio与Jwt核心集成部分就算是结束了。那我们接着聊聊Session服务器的作用之一:解决token窃持。先上图:
鉴权.jpg
何为token窃持? 即发送给客户端的token令牌被第三方中间人获取,中间人通过此token模拟合法身份进行恶意请求。那如上架构是如何解决的呢?核心的思路是令牌必须只能消费一次,基于Session服务器利用缓存或其他存储介质完成token认证留痕。凡是存在消费记录的token都会被记录下来,即可判断出是否是重复使用。当然如果请求量大,对于缓存的存储压力也是比较大的。个人设计的Jwt内部结构如下:
//userName为持有人,expiresDate为过期时间
{"expires":30,"expiresDate":1582945397426,"userName":"userName"}
从上图可以看出鉴权分为本地鉴权与远程鉴权:本地鉴权根据expiresDate做判断,集群环境下需要保持时钟一致性。 本地鉴权通过-->远程鉴权,采用Redis作为存储介质可以设置过期时间 > expiresDate即可。如token有效期30分钟,缓存可以设置为35分钟即可,有效解决了token大量堆积问题。
灵魂一问:假设窃持发生在第一次请求时,该如何处理呢?即token未被消费时即被窃持了。好吧,有时间再写下一篇吧。核心思路是:数据防篡改。
PS:题外话1:窃持问题不仅仅是token设计独有的,HttpSession也有。它是同种问题的变种延伸。题外话2:有条件建议全站Https,真的可以省去很多问题。
网友评论