前言HttpSessionRedisSessionJwt+RedisSessionJWT+RedisSession+shiro的分布式Session权限控制方案
RedisSessionSubjectFactorySessionKey:SsoSecurityManager : git
前言前面对shiro的认证流程进行了分析:大致回顾总结下:
- 我们在ShiroFilterFactoryBean中设置对请求的拦截。请求到来后先通过请求路径匹配到对应的filter;以所有请求需要经过formAuthenticationFilter为例;请求先经过isAccessAllowed的验证,验证逻辑是:当前的subject是认证通过的,或当前请求不是登录请求且是被允许的。满足则放行;如果没有通过isAccessAllowed的验证,则会进入到onAccessDenied进行验证不通过的处理。如果是登录请求, formAuthenticationFilter中对于登录请求会通过配置的username、password、rememberMe创建一个UsernamePasswordToken然后进行登录逻辑。登录的时候回通过配置的Realm获取AuthenticationInfo,并且通过Realm中配置的凭证验证器进行凭证校验。如果通过Realm获取到了合法的AuthenticationInfo则登录请求完成。登录成功后会执行一系列回调操作。包括把当前的subject的认证状态改成true和把当前的subject写到session中等操作。如果在请求不是登录请求,那么就会进行重定向到配置的登录页面。
最简单的shiro控制场景下,浏览器发起认证请求后shiro会先创建Subject,然后进行认证,认证成功后会吧认证通过信息写到session中,session通过HttpServletRequest获取。初次访问的时候,HttpServeltRequest就会在服务端创建一个HttpSession存在内存中,然后与浏览器通过JSESSIONID来保持Session状态。
RedisSession但是在某些情况下,HttpSession失去了他的作用,比如后端服务采用了集群或分布式部署。这个时候就需要一个能够共享的Session,我们一般选择redis替换Tomcat的内存存储Session。然后通过JSESSIONID去取Session。
Jwt+RedisSession有些浏览器或者客户端在某些情况下会导致JSESSIONID失效,即每次发起的请求都跟是个新的请求一样,丢失Session状态。所以避免这种情况,我们可以用JWT来替换JSESSIONID。这个时候客户端和服务端Session的关系维持就可以交给我们自己维护了。
JWT+RedisSession+shiro的分布式Session权限控制方案首先基于Session接口定义RedisSession:
RedisSessionpublic class RedisSession implements Session,Serializable {
private String id;
private Date startTimestamp;
private Date stopTimestamp;
private Date lastAccessTime;
private long timeout;
private String host;
private Map
Session的管理呢是Subject管理的,那我们需要定义一个自己的Subject吗?研究源码的时候我发现目前并不需要,在shiro中是交由WebDelegatingSubject进行代理的。所以我们只需要修改Subject的创建过程参考Request中的Token就好了。
所以我们需要重写SubjectFactory:
public class SsoSubjectFactory extends DefaultWebSubjectFactory {
@Override
public Subject createSubject(SubjectContext context) {
if (!(context instanceof WebSubjectContext)) {
return super.createSubject(context);
}
WebSubjectContext wsc = (WebSubjectContext) context;
SecurityManager securityManager = wsc.resolveSecurityManager();
Session session = wsc.resolveSession();
ServletRequest request = wsc.resolveServletRequest();
String token=JwtUtil.getJwt((HttpServletRequest)request);
//如果session是空的,那么从request中获取token,然后去redis中获取session
if(session==null && token!=null){
RedisSessionKey sessionKey = new RedisSessionKey(token);
session = securityManager.getSession(sessionKey);
}
//如果前面获取到了session,则把sessionId放到response中
if(session!=null){
JwtUtil.setAuthorizationToken((HttpServletResponse) wsc.resolveServletResponse(), (String) session.getId());
}
boolean sessionEnabled = wsc.isSessionCreationEnabled();
PrincipalCollection principals = wsc.resolvePrincipals();
boolean authenticated = wsc.resolveAuthenticated();
String host = wsc.resolveHost();
ServletResponse response = wsc.resolveServletResponse();
return new WebDelegatingSubject(principals, authenticated, host, session, sessionEnabled,
request, response, securityManager);
}
}
我们在创建Subject的时候就给他把Session塞进去,这样shiro以后就不会再去创建session了。从源码分析也能看到Session可以通过org.apache.shiro.mgt.SessionsSecurityManager#getSession进行获取,而getSession的参数是SessionKey,所以根据我的设计方案,我自己搞了一个RedisSessionKey来替换原来的SessionKey,并且自己对DefaultWebSecurityManager进行复写来修改其Subject和Session的管理逻辑:
SessionKey:public class RedisSessionKey implements SessionKey {
private String id;
public RedisSessionKey(String id) {
this.id = id;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public void setSessionId(Serializable sessionId){
this.id = (String) sessionId;
}
@Override
public Serializable getSessionId() {
return id;
}
}
SsoSecurityManager :
public class SsoSecurityManager extends DefaultWebSecurityManager {
RedisTemplate redisTemplate;
public SsoSecurityManager(RedisTemplate redisTemplate) {
super();
this.redisTemplate = redisTemplate;
}
@Override
public boolean isHttpSessionMode() {
return false;
}
@Override
protected SessionKey getSessionKey(SubjectContext context) {
if (WebUtils.isWeb(context)) {
HttpServletRequest request = WebUtils.getHttpRequest(context);
String authorization = JwtUtil.getJwt(request);
return new RedisSessionKey(authorization);
}
return null;
}
@Override
protected Subject createSubject(AuthenticationToken token, AuthenticationInfo info, Subject existing) {
return super.createSubject(token, info, existing);
}
@Override
public Session start(SessionContext context) throws AuthorizationException {
RedisSession session = new RedisSession();
HttpServletRequest request = WebUtils.getHttpRequest(context);
String host = context.getHost()==null?request.getRemoteHost():context.getHost();
String sessionId = JwtUtil.getJwt(request);
//第一次访问,生成默认的token
if(sessionId == null){
sessionId = JwtUtil.getUnauthorizedToken(host);
HttpServletResponse response = WebUtils.getHttpResponse(context);
JwtUtil.setAuthorizationToken(response,sessionId);
}
session.setId(sessionId);
context.setSessionId(sessionId);
//创建好的session缓存到redis后即表示启动了
cacheSession(session);
return session;
}
public void cacheSession(RedisSession session) {
redisTemplate.opsForValue().set(
JwtConstans.getCacheSessionId(session.getId())
,session
,JwtConstans.SESSION_EFFECTIVE_TIME
,JwtConstans.SESSION_EFFECTIVE_TIME_UNIT);
}
@Override
public Session getSession(SessionKey key) throws SessionException {
if(key==null || key.getSessionId()==null){
return null;
}
String cacheKey = JwtConstans.getCacheSessionId((String) key.getSessionId());
RedisSession session = (RedisSession) redisTemplate.opsForValue().get(cacheKey);
return session;
}
public void stopSession(String sessionId) {
redisTemplate.delete(JwtConstans.getCacheSessionId(sessionId));
}
public void touchSession(RedisSession redisSession) {
cacheSession(redisSession);
}
public void touchSessionId(String oldSessionId,String newSessionId){
//通过sessionId找到先前的session
RedisSession session = (RedisSession) this.getSession(new RedisSessionKey(oldSessionId));
//移除旧的session
this.stopSession(oldSessionId);
//更新sessionid,并存入redis
session.setId(newSessionId);
this.cacheSession(session);
}
}
shiro中通常进行权限拦截用的是FormAuthenticationFilter进行拦截,但是我们这里要支持单点登录,所以登录方式不再是单纯的根据用户名密码登录,所以这里创建一个JwtFilter来替换。FormAuthenticationFilter
上面的基础准备好了后,开始进行shiro配置:
SsoClientShiroConfig:
@Configuration
public class SsoClientShiroConfig {
@Autowired
SsoConfig ssoConfig;
private static Map filters = new linkedHashMap<>();
@Bean
@ConditionalOnMissingBean
public ISSoServerUserService IssoServerUserService(){
if(ssoConfig.isSsoServer()){
Log.get().log(Level.WARN,"检测到当前服务是单点登录验证服务,但未找到{}的实现类,当前服务将被降级为单点登录客户端,可能无法提供登录相关操作",ISSoServerUserService.class);
Log.get().log(Level.WARN,"请检查sso.config.ssoServer配置:true->单点登录服务;false->单点登录客户端。若确定是单点登录服务请实现{}接口",ISSoServerUserService.class);
ssoConfig.setSsoServer(false);
}
return new DefaultSSoServerUserServiceImpl();
}
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,ISSoServerUserService ssoServerUserService){
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
shiroFilterFactoryBean.setFilters(filters);
CaptchaFilter captchaFilter = new CaptchaFilter();
IndexFilter indexFilter = new IndexFilter();
SsoAuthenticationFilter authc = new SsoAuthenticationFilter(ssoConfig);
filters.put("captcha",captchaFilter);
filters.put("authc",authc);
filters.put("index",indexFilter);
//拦截器
Map filterChainDefinitionMap = new linkedHashMap<>();
shiroFilterFactoryBean.setLoginUrl("/login");
shiroFilterFactoryBean.setSuccessUrl("/index");
//未授权界面;
shiroFilterFactoryBean.setUnauthorizedUrl("/403");
filterChainDefinitionMap.put("/captcha.jpg","captcha");
//如果是单点登录服务端才配置登录相关请求,客户端的login请求会交给authc处理
if(ssoConfig.isSsoServer()){
filters.put("login",new LoginFilter(ssoServerUserService,ssoConfig));
filters.put("logout",new LogoutFilter());
//根据单点配置决定是否启用验证码校验
if(ssoConfig.isCaptcha()){
filterChainDefinitionMap.put("/login","captcha,login");
}else{
filterChainDefinitionMap.put("/login","login");
}
}
filterChainDefinitionMap.put("/logout","logout");
filterChainDefinitionMap.put("/index","index");
filterChainDefinitionMap.put("
@Bean
public static HashedCredentialsMatcher hashedCredentialsMatcher(){
HashedCredentialsMatcher hashedCredentialsMatcher = new SsoCredentialsMatch();
hashedCredentialsMatcher.setHashAlgorithmName("md5");//散列算法:这里使用MD5算法;
hashedCredentialsMatcher.setHashIterations(2);//散列的次数,比如散列两次,相当于 md5(md5(""));
return hashedCredentialsMatcher;
}
@Bean
public MyShiroRealm myShiroRealm(HashedCredentialsMatcher hashedCredentialsMatcher){
MyShiroRealm myShiroRealm = new MyShiroRealm();
myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher);
myShiroRealm.setCachingEnabled(false);
return myShiroRealm;
}
@Bean
public SessionsSecurityManager securityManager(MyShiroRealm shiroRealm,RedisTemplate redisTemplate){
SsoSecurityManager securityManager = new SsoSecurityManager(redisTemplate);
securityManager.setRealm(shiroRealm);
securityManager.setSubjectFactory(subjectFactory());
return securityManager;
}
@Bean
public SubjectFactory subjectFactory(){
return new SsoSubjectFactory();
}
}
通过上面的配置后,session就不再是HttpSession了,session的管理也交给redis管理了。下面再上几张测试图:
登录成功后在response的header中会有authorization信息,前端这个请求其他接口的时候带上这个authorization,那就可以无状态的访问服务端了,服务端通过authorization再从redis中取得session,从而实现无状态的session。
前面的描述可能不太完善,如果有兴趣可以看我开源的仓库:https://gitee.com/liu0829/redis-shiro-sso.git
也可以发邮件联系我:liuwanli_email@163.com
各位有什么要补充纠正的,请留下你的宝贵意见。



