JWT+RedisSession+shiro的分布式Session权限控制方案
前言
前面对shiro的认证流程进行了分析:大致回顾总结下:
- 我们在ShiroFilterFactoryBean中设置对请求的拦截。
- 请求到来后先通过请求路径匹配到对应的filter;
- 以所有请求需要经过formAuthenticationFilter为例;
- 请求先经过isAccessAllowed的验证,验证逻辑是:当前的subject是认证通过的,或当前请求不是登录请求且是被允许的。满足则放行;
- 如果没有通过isAccessAllowed的验证,则会进入到onAccessDenied进行验证不通过的处理。
- 如果是登录请求, formAuthenticationFilter中对于登录请求会通过配置的username、password、rememberMe创建一个UsernamePasswordToken然后进行登录逻辑。
- 登录的时候回通过配置的Realm获取AuthenticationInfo,并且通过Realm中配置的凭证验证器进行凭证校验。
- 如果通过Realm获取到了合法的AuthenticationInfo则登录请求完成。
- 登录成功后会执行一系列回调操作。包括把当前的subject的认证状态改成true和把当前的subject写到session中等操作。
- 如果在请求不是登录请求,那么就会进行重定向到配置的登录页面。
HttpSession
最简单的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:
RedisSession
public 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<Object, Object> attributes;
public RedisSession() {
this.timeout = DefaultSessionManager.DEFAULT_GLOBAL_SESSION_TIMEOUT;
this.startTimestamp = new Date();
this.lastAccessTime = this.startTimestamp;
}
public RedisSession(String host) {
this();
this.host = host;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public Date getStartTimestamp() {
return startTimestamp;
}
public void setStartTimestamp(Date startTimestamp) {
this.startTimestamp = startTimestamp;
}
public Date getStopTimestamp() {
return stopTimestamp;
}
public void setStopTimestamp(Date stopTimestamp) {
this.stopTimestamp = stopTimestamp;
}
public Date getLastAccessTime() {
return lastAccessTime;
}
public void setLastAccessTime(Date lastAccessTime) {
this.lastAccessTime = lastAccessTime;
}
public long getTimeout() {
return timeout;
}
public void setTimeout(long timeout) {
this.timeout = timeout;
}
public String getHost() {
return host;
}
public void setHost(String host) {
this.host = host;
}
public void stop() {
this.stopTimestamp = new Date();
SsoSecurityManager securityManager = (SsoSecurityManager) SecurityUtils.getSecurityManager();
securityManager.stopSession(this.getId());
}
public void touch() {
this.lastAccessTime = new Date();
SsoSecurityManager securityManager = (SsoSecurityManager) SecurityUtils.getSecurityManager();
securityManager.touchSession(this);
}
public Collection<Object> getAttributeKeys() throws InvalidSessionException {
return attributes==null? Collections.emptySet():attributes.values();
}
@Override
public Object getAttribute(Object key) throws InvalidSessionException {
return attributes==null?null:attributes.get(key);
}
@Override
public void setAttribute(Object key, Object value) throws InvalidSessionException {
if(attributes==null){
attributes = new HashMap<>();
}
attributes.put(key,value);
touch();
}
public Object removeAttribute(Object key) {
if(attributes==null){
return null;
}
Object o = attributes.remove(key);
touch();
return o;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj instanceof RedisSession) {
RedisSession other = (RedisSession) obj;
Serializable thisId = getId();
Serializable otherId = other.getId();
if (thisId != null && otherId != null) {
return thisId.equals(otherId);
} else {
//fall back to an attribute based comparison:
return onEquals(other);
}
}
return false;
}
protected boolean onEquals(RedisSession ss) {
return (getStartTimestamp() != null ? getStartTimestamp().equals(ss.getStartTimestamp()) : ss.getStartTimestamp() == null) &&
(getStopTimestamp() != null ? getStopTimestamp().equals(ss.getStopTimestamp()) : ss.getStopTimestamp() == null) &&
(getLastAccessTime() != null ? getLastAccessTime().equals(ss.getLastAccessTime()) : ss.getLastAccessTime() == null) &&
(getTimeout() == ss.getTimeout()) &&
(getHost() != null ? getHost().equals(ss.getHost()) : ss.getHost() == null) &&
(getAttributes() != null ? getAttributes().equals(ss.getAttributes()) : ss.getAttributes() == null);
}
private Map<Object,Object> getAttributes() {
return attributes;
}
@Override
public int hashCode() {
return getId().hashCode();
}
@Override
public String toString() {
return getClass().getName() + ",id=" + getId();
}
public void setAttributes(Map<Object, Object> attributes) {
this.attributes = attributes;
}
}
Session的管理呢是Subject管理的,那我们需要定义一个自己的Subject吗?研究源码的时候我发现目前并不需要,在shiro中是交由WebDelegatingSubject进行代理的。所以我们只需要修改Subject的创建过程参考Request中的Token就好了。
所以我们需要重写SubjectFactory:
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;
}
/**
* 修改sessionKey的获取逻辑是从requst中获取jwt信息作为session的id,其作用类似JSESSIONID
* @param context
* @return
*/
@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;
}
/**
* 调用其父类的方法,最终创建subject的方法交个subjectFactory做
* @param token
* @param info
* @param existing
* @return
*/
@Override
protected Subject createSubject(AuthenticationToken token, AuthenticationInfo info, Subject existing) {
return super.createSubject(token, info, existing);
}
/***
* 启用session,shiro中是设置session的开始时间。我们这里用自己定义的RedisSession来替换http的session
* ,session存入redis则表示session的启用,session的id取jwt,如果第一次访问,没有生成jwt,则生成一个未授权的jwt作为id
* @param context
* @return
* @throws AuthorizationException
*/
@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;
}
/**
* 缓存session
* @param session
*/
public void cacheSession(RedisSession session) {
redisTemplate.opsForValue().set(
JwtConstans.getCacheSessionId(session.getId())
,session
,JwtConstans.SESSION_EFFECTIVE_TIME
,JwtConstans.SESSION_EFFECTIVE_TIME_UNIT);
}
/**
* session的获取方式改为从redis中获取
* @param key
* @return
* @throws SessionException
*/
@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;
}
/**
* 关闭session,实现方式:直接从redis中移除session
* @param sessionId
*/
public void stopSession(String sessionId) {
redisTemplate.delete(JwtConstans.getCacheSessionId(sessionId));
}
public void touchSession(RedisSession redisSession) {
cacheSession(redisSession);
}
/**
* 更新sessionid
* @param oldSessionId
* @param newSessionId
*/
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<String, Filter> 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<String,String> 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("/**","authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
/**
* 凭证匹配器
* (由于我们的密码校验交给Shiro的SimpleAuthenticationInfo进行处理了
* )
* @return
*/
@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;
}
/*****ssoSubjectFactory和SsoSubjectDao禁用******/
@Bean
public SubjectFactory subjectFactory(){
return new SsoSubjectFactory();
}
}
通过上面的配置后,session就不再是HttpSession了,session的管理也交给redis管理了。下面再上几张测试图:
登录成功后在response的header中会有authorization信息,前端这个请求其他接口的时候带上这个authorization,那就可以无状态的访问服务端了,服务端通过authorization再从redis中取得session,从而实现无状态的session。
git
前面的描述可能不太完善,如果有兴趣可以看我开源的仓库:https://gitee.com/liu0829/redis-shiro-sso.git
也可以发邮件联系我:liuwanli_email@163.com
各位有什么要补充纠正的,请留下你的宝贵意见。