CAS单点登录系统实现强制用户下线退出

需求背景

当管理员冻结用户时, 立即退出所有服务的登陆状态

当前项目架构

用户管理系统: 管理用户的状态与权限等用户信息

CAS单点登录系统: 对用户进行认证授权

管理后台: 公司的微服务系统

当前认证流程

在这里插入图片描述
红色虚线内为用户与CAS交互的部分

也是后边需要改动CAS认证流程逻辑的部分

实现冻结立即下线

下线实现

管理后台开放的一个删除Redis中Session的接口 /session/deleteByUserId

然后管理员在用户服务进行冻结操作时调用管理后台的 /session/deleteByUserId 接口

这时所有用户的Session信息因被删除而失效就实现了用户下线的功能

根据用户ID删除Session

通过代码找到用户生成Session的方法

org.apache.shiro.session.mgt.eis.AbstractSessionDAO#generateSessionId

该方法由自定义的 SessionRedisDao.java 所调用

生成的是一个UUID作sessionId

然后用sessionId作为redis的Key

那么要实现根据用户ID删除redis的session就必须key上标记用户的id

最简单的办法就是生成sessionId后, 在sessionId前加上用户id

这时redis key的结构为 前缀:用户ID:SessionId

问题点在于如何获取用户ID?

因为Session创建时间是用户访问就会生成,

也就是用户没登录时就已经生成了session, 这个时间是获取不到用户ID的

解决办法

未登录用户的session不进行保存, 用户未登录时每次访问都会重新创建session,

当用户处于已登录状态时才保存session

源码分析

Session创建的方法

org.apache.shiro.session.mgt.SimpleSessionFactory#createSession

    public Session createSession(SessionContext initData) {
        if (initData != null) {
            String host = initData.getHost();
            if (host != null) {
                return new SimpleSession(host);
            }
        }
        return new SimpleSession();
    }

可以看到Session最开始是使用SessionContext进行创建的

SessionContext创建的方法

org.apache.shiro.web.subject.support.WebDelegatingSubject#createSessionContext

    protected SessionContext createSessionContext() {
        WebSessionContext wsc = new DefaultWebSessionContext();
        String host = getHost();
        if (StringUtils.hasText(host)) {
            wsc.setHost(host);
        }
        wsc.setServletRequest(this.servletRequest);
        wsc.setServletResponse(this.servletResponse);
        return wsc;
    }

可以看到方法内写死了new DefaultWebSessionContext(), 没有可扩展的可能性.

那么只能从WebDelegatingSubject类看看能不能重写这个方法

WebDelegatingSubjectl创建的方法

org.apache.shiro.mgt.DefaultSubjectFactory#createSubject

    public Subject createSubject(SubjectContext context) {
        SecurityManager securityManager = context.resolveSecurityManager();
        Session session = context.resolveSession();
        boolean sessionCreationEnabled = context.isSessionCreationEnabled();
        PrincipalCollection principals = context.resolvePrincipals();
        boolean authenticated = context.resolveAuthenticated();
        String host = context.resolveHost();

        return new DelegatingSubject(principals, authenticated, host, session, sessionCreationEnabled, securityManager);
    }

这里看到创建处也写死了new DelegatingSubject(), 也无法扩展,

再看看DefaultSubjectFactory能不能重写这个方法

CasSubjectFactory创建的方法

@Bean(name = "casSubjectFactory")
public CasSubjectFactory casSubjectFactory() {
    return new CasSubjectFactory();
}

@Bean(name = "webSecurityManager")
public DefaultWebSecurityManager webSecurityManager(CasRealm casRealm, SessionManager sessionManager, CasSubjectFactory casSubjectFactory) {
    DefaultWebSecurityManager webSecurityManager = new DefaultWebSecurityManager();
    webSecurityManager.setRealm(casRealm);
    webSecurityManager.setSessionManager(sessionManager);
    webSecurityManager.setSubjectFactory(casSubjectFactory);

    SecurityUtils.setSecurityManager(webSecurityManager);

    return webSecurityManager;
}

这里已经来到了我们自己的配置类中

这个类是我们自己创建然后set到DefaultWebSecurityManager中的,

只要在这里替换掉这个类为自己重写方法后的类就可以实现了

重写的类

自己的SessionContex

public class XhWebSessionContext extends DefaultWebSessionContext {

    private PrincipalCollection principals;

    public PrincipalCollection getPrincipals() {
        return principals;
    }

    public void setPrincipals(PrincipalCollection principals) {
        this.principals = principals;
    }
}

自己的DelegatingSubject

将认证信息放入SessionContext中

public class XhWebDelegatingSubject extends WebDelegatingSubject {

    public XhWebDelegatingSubject(PrincipalCollection principals, boolean authenticated, String host, Session session, ServletRequest request, ServletResponse response, SecurityManager securityManager) {
        super(principals, authenticated, host, session, request, response, securityManager);
    }

    public XhWebDelegatingSubject(PrincipalCollection principals, boolean authenticated, String host, Session session, boolean sessionEnabled, ServletRequest request, ServletResponse response, SecurityManager securityManager) {
        super(principals, authenticated, host, session, sessionEnabled, request, response, securityManager);
    }

    @Override
    protected SessionContext createSessionContext() {
        XhWebSessionContext wsc = new XhWebSessionContext();
        String host = getHost();
        if (StringUtils.hasText(host)) {
            wsc.setHost(host);
        }
        wsc.setServletRequest(super.getServletRequest());
        wsc.setServletResponse(super.getServletResponse());
        // 用户认证信息
        wsc.setPrincipals(super.principals);
        return wsc;
    }
}

配置中替换原来的CasSubjectFactory为自己的

@Bean(name = "casSubjectFactory")
public XhCasSubjectFactory casSubjectFactory() {
    return new XhCasSubjectFactory();
}

@Bean(name = "webSecurityManager")
public DefaultWebSecurityManager webSecurityManager(CasRealm casRealm, SessionManager sessionManager, XhCasSubjectFactory casSubjectFactory) {
    DefaultWebSecurityManager webSecurityManager = new DefaultWebSecurityManager();
    webSecurityManager.setRealm(casRealm);
    webSecurityManager.setSessionManager(sessionManager);
    webSecurityManager.setSubjectFactory(casSubjectFactory);

    SecurityUtils.setSecurityManager(webSecurityManager);

    return webSecurityManager;
}

接下来重写创建Session的方法

public class XhSessionFactory extends SimpleSessionFactory {

    @Override
    public Session createSession(SessionContext initData) {
        if (initData instanceof XhWebSessionContext) {
            XhWebSessionContext sessionContext = (XhWebSessionContext) initData;
            // 用户认证信息
            PrincipalCollection principals = sessionContext.getPrincipals();
            if (principals != null) {
                // 有用户认证信息说明已经登录, 只有登录了才使用自己实现的Session
                String priUserLoginName = (String) sessionContext.getPrincipals().getPrimaryPrincipal();
                // 这里用的是自己实现的Session, 把用户名保存进去
                XhSession session = new XhSession();
                session.setPriUserLoginName(priUserLoginName);
                return session;
            }
        }
        return super.createSession(initData);
    }
}

配置自己的SessionFactory

    @Bean(name = "sessionManager")
    public DefaultWebSessionManager defaultWebSessionManager(XhSessionRedisDao sessionDAO, SimpleCookie sessionIdCookie) {
        DefaultWebSessionManager sessionManager =  new DefaultWebSessionManager();
        sessionManager.setGlobalSessionTimeout(SessionTimeout);
        sessionManager.setDeleteInvalidSessions(true);
        sessionManager.setSessionValidationSchedulerEnabled(true);
        sessionManager.setSessionDAO(sessionDAO);
        sessionManager.setSessionIdCookieEnabled(true);
        sessionManager.setSessionIdCookie(sessionIdCookie);
        // 用的是重写了方法的SessionFactory
        sessionManager.setSessionFactory(new XhSessionFactory());
        return sessionManager;
    }

这里重写了Session

因为默认的Session实现类是SimpleSession

这个类没有可以存放用户信息的字段, 我们必须加一个字段来存

public class XhSession extends SimpleSession {
    private String priUserLoginName;

    public String getPriUserLoginName() {
        return priUserLoginName;
    }

    public void setPriUserLoginName(String priUserLoginName) {
        this.priUserLoginName = priUserLoginName;
    }
}

SessionId的创建与保存

该方法位于SessionRedisDao

public class XhSessionRedisDao extends CachingSessionDAO {
    ...
	@Override
    protected Serializable doCreate(Session session) {
    	// 创建sessionId, 内容为UUID
        Serializable sessionId = generateSessionId(session);
        // 判断类型为XhSession说明已经登录, 有用户信息
        if (session instanceof XhSession) {
            XhSession xhSession = ((XhSession) session);
            String priUserLoginName = xhSession.getPriUserLoginName();
            try {
                // sessionId 前增加用户id
                // 调用用户管理服务查询用户信息
                Result<UserVO> result = privilegeRemoteClient.selectUserByLoginName(priUserLoginName);
                UserVO user = result.getModel();
                if (user != null) {
                    // 查询成功拼接用户id并设置到session中且返回
                    sessionId = user.getId() + ":" + sessionId;
                    xhSession.setId(sessionId);
                    // 保存到Redis中
                    ValueOperations<Serializable, Session> opsForValue = redisTemplate.opsForValue();
                    opsForValue.set(getKey(sessionId), session, Constant.CAS_EXPIRE_TIME, TimeUnit.SECONDS);

                    return sessionId;
                }
            } catch (Exception e) {
                log.error("查询用户信息失败:{}", priUserLoginName, e);
            }
        }
        // 未登录不保存sid
        assignSessionId(session, sessionId);
        // 该缓存用于该sid能在本次请求维持shiro框架的运转
        // 该session仅保存在ThreadLocal中, 请求结束便会失效(过滤器结束时清写代码理掉)
        SessionUtil.setSession(session);
        return sessionId;
    }
    ...
}

redis key生成方法

private String getKey(Serializable sessionId) {
    return CasConstants.REDIS_KEY_PREFIX + sessionId.toString();
}

根据用户ID删除Session

public class XhSessionRedisDao extends CachingSessionDAO {
...
	public void doDeleteByUserId(Integer priUserId) {
        // 查询用户下的所有Session的KEY
        Set<Serializable> keys = redisTemplate.keys(getKey(priUserId) + ":*");
        Cache<Serializable, Session> activeSessionsCache = super.getActiveSessionsCache();
        if (keys == null) {
            return;
        }
        // 遍历删除Session以及本地缓存
        for (Serializable key : keys) {
            redisTemplate.delete(key);
            if (activeSessionsCache != null) {
                activeSessionsCache.remove(key.toString().substring(CasConstants.REDIS_KEY_PREFIX.length()));
            }
        }
    }
...
}

CAS服务的退出

起初对该认证流程不了解,

将用户数据库的状态改为冻结后,

只做了客户端的退出, 把管理后台Redis中的Session删除,

当用户访问首页时确实返回了未认证跳转到CAS服务进行登陆.

用户跳转到CAS服务时, CAS服务会对Cookies中的CASTGC信息进行内存查找,

由于之前在CAS登录过所以CASTGC是有效的, 则不会要求账户密码登录.

而是直接发放授权跳转回后台的/cas/auth?ticket=XXX

这时后台又重新获得了登录

这期间CAS完全没有查询数据库, 所以CAS服务并不知道用户已被冻结

解决办法

当CAS从使用CASTGC值获取到用户的ticket认证信息时,

再查一次数据库确保用户当前状态是有效的才允许通过

修改CAS服务的代码

该功能代码改动位置 CentralAuthenticationServiceImpl.java

	// 引入数据库
	private SimpleJdbcTemplate jdbcTemplate;

    public final void setDataSource(final DataSource dataSource) {
        this.jdbcTemplate = new SimpleJdbcTemplate(dataSource);
    }

	// 验证用户的sql
	private String validationSql;

    public void setValidationSql(String validationSql) {
        this.validationSql = validationSql;
    }
    
	// 获取ticket的方法
    public String grantServiceTicket(final String ticketGrantingTicketId,
        final Service service, final Credentials credentials)
        throws TicketException {
        if (credentials != null) {
            ...
        }
        // 增加这一段代码
        else {
            // 从ticket中获取用户认证信息
            Authentication authentication = ticketGrantingTicket.getAuthentication();
            Principal principal = authentication.getPrincipal();
            // 拿到用户名进行数据查询
            String username = principal.getId();
            List<Map<String, Object>> list = this.jdbcTemplate.queryForList(
                    this.validationSql, username);
            if (list == null || list.isEmpty()) {
                // 如果查询不到, 说明用户已被冻结或被删除
                this.ticketRegistry.deleteTicket(ticketGrantingTicket.getId());
                log.info("Token validation failed by query database account_name, token:" + ticketGrantingTicket.getId());
                // 抛出ticket无效异常即可
                throw new InvalidTicketException();
            }
        }
    }

最后将 applicationContext.xml 配置的 centralAuthenticationService Bean

把数据源和 validationSql 的SQL语句配置好

<property name="dataSource" ref="dataSource" />
<property name="validationSql" value="select `name` from `user` where `is_delete` = 0 and `status` = 1 and `name` = ?" />

后话

这里多亏这篇文章的帮助

https://blog.csdn.net/dovejing/article/details/44523545

我用的还是CAS 3.x的版本, 各种xml配置, 如果完全自己调试找出核心代码得吃不少苦头

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值