JWT+RedisSession+shiro的分布式Session权限控制方案

前言

前面对shiro的认证流程进行了分析:大致回顾总结下:

  1. 我们在ShiroFilterFactoryBean中设置对请求的拦截。
  2. 请求到来后先通过请求路径匹配到对应的filter;
  3. 以所有请求需要经过formAuthenticationFilter为例;
  4. 请求先经过isAccessAllowed的验证,验证逻辑是:当前的subject是认证通过的,或当前请求不是登录请求且是被允许的。满足则放行;
  5. 如果没有通过isAccessAllowed的验证,则会进入到onAccessDenied进行验证不通过的处理。
  6. 如果是登录请求, formAuthenticationFilter中对于登录请求会通过配置的username、password、rememberMe创建一个UsernamePasswordToken然后进行登录逻辑。
  7. 登录的时候回通过配置的Realm获取AuthenticationInfo,并且通过Realm中配置的凭证验证器进行凭证校验。
  8. 如果通过Realm获取到了合法的AuthenticationInfo则登录请求完成。
  9. 登录成功后会执行一系列回调操作。包括把当前的subject的认证状态改成true和把当前的subject写到session中等操作。
  10. 如果在请求不是登录请求,那么就会进行重定向到配置的登录页面。

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

各位有什么要补充纠正的,请留下你的宝贵意见。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值