需求背景
当管理员冻结用户时, 立即退出所有服务的登陆状态
当前项目架构
用户管理系统: 管理用户的状态与权限等用户信息
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配置, 如果完全自己调试找出核心代码得吃不少苦头