应用背景
过去若是部署多台单点登录系统,会通过nginx配置做会话保持,从而保证不同客户端发起的登录请求会一直落在同一台机器,保证正常登录,nginx配置如图举例:
这里nginx会话保持策略采用的是ip_hash。
后随着系统的拓展,以及日常中实际工作的发现,在nginx上做会话保持有一定的弊端,比如:现在有A、B、C三台服务,不同客户端发起的请求会均衡的分布在A、B、C上,这个时候如果C宕机,nginx会把本该到C的请求均衡的分步在A、B上,此时C服务通过处理恢复正常了,这时的nginx由于会话保持,不会再给C分配请求,那么C此时就会一直处于空闲状态,因此需要去掉nginx层面的会话保持策略,这样每一次的请求均会轮询分配在每一台服务上,当宕机的服务又回来时仍然可以获取请求。
当去掉nginx会话保持时,SSO系统会出现在进入登录页面时在A上生成了验证码,默认放在了A的session,而提交时请求到了B上,而B的session中没有页面提交过来的验证码导致登录验证不通过
SSO系统验证码存入redis
如果要将验证码存入redis,那么就需要一个能够标示当前客户端的唯一的id作为key,这是就需要在流程开始类InitialFlowSetupAction.java中增加参数放在context.getFlowScope()中放在页面隐藏域中
同时在casLoginView.jsp中放置隐藏域,放入uuid
同时更改原来的获取验证码方法,传入当前隐藏域的uuid用于生成验证码后存入redis的key
原验证码存储
下面再改造生成验证码的类CaptchaImageCreateController.java
进一步跟进生成验证码的方法 java.awt.image.BufferedImage challenge = jcaptchaService.getImageChallengeForID(captchaId, request.getLocale());
进去可以看到返回 (BufferedImage)this.getChallengeForID(ID, locale);
再继续向下跟可以看到 captcha = this.generateAndStoreCaptcha(locale, ID);
也就是当前这个方法captcha = this.generateAndStoreCaptcha(locale, ID);生成验证码和存储验证码的方法
查看当前类可以看到此处的this.store是CaptchaStore
那么回到cas-servlet.xml可以看到CaptchaStore用的是FastHashMapCaptchaStore.java
而FastHashMapCaptchaStore又继承自MapCaptchaStore
打开MapCaptchaStore.java 可以看到存储验证码的方法,此处的this.store用的是FastHashMap,最终原来验证码是以hashMap的形式放在服务器session中的
这里还有另一种找到验证码存储位置的入口,比如
点进去之后继续跟进
最终也是会找到原来的验证码是通过CaptchaStore存储的。
现验证码存储
找到了原始验证码实现存储的类,那么就只需要改造该类并引入即可,首先改造为新的存储类 RedisCaptchaStore.java
package org.jasig.cas.captcha; import com.octo.captcha.Captcha; import com.octo.captcha.service.CaptchaServiceException; import com.octo.captcha.service.captchastore.CaptchaAndLocale; import com.octo.captcha.service.captchastore.CaptchaStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.redis.core.RedisTemplate; import javax.validation.constraints.Min; import javax.validation.constraints.NotNull; import java.util.Collection; import java.util.Locale; import java.util.concurrent.TimeUnit; /** * @ClassName:RedisCaptchaStore * @author:dongao * @date 2021/12/21 13:05 */ public class RedisCaptchaStore implements CaptchaStore { @NotNull private RedisTemplate<String, Object> redisTemplate; /** * redis 过期时间 */ @Min(0) private final int keyTimeout; public RedisCaptchaStore(int keyTimeout) { this.keyTimeout = keyTimeout; } private final Logger logger = LoggerFactory.getLogger(RedisCaptchaStore.class); @Override public boolean hasCaptcha(String id) { try { return redisTemplate.hasKey(id); } catch (Exception e) { logger.info("redisTemplate hasKey({}) failure. error message {}",id,e.getMessage()); return false; } } @Override public void storeCaptcha(String id, Captcha captcha) throws CaptchaServiceException { try { redisTemplate.opsForValue().set(id,new CaptchaAndLocale(captcha),keyTimeout, TimeUnit.SECONDS); } catch (Exception e) { logger.info("redisTemplate set({}) failure. error message {}",id,e.getMessage()); } } @Override public void storeCaptcha(String id, Captcha captcha, Locale locale) throws CaptchaServiceException { try { redisTemplate.opsForValue().set(id,new CaptchaAndLocale(captcha, locale),keyTimeout,TimeUnit.SECONDS); } catch (Exception e) { logger.info("redisTemplate set({}) failure. error message {}",id,e.getMessage()); } } @Override public boolean removeCaptcha(String id) { try { Object object = redisTemplate.opsForValue().get(id); if(object != null) { redisTemplate.delete(id); return true; } else { return false; } } catch (Exception e) { logger.info("redisTemplate delete({}) failure. error message {}",id,e.getMessage()); return false; } } @Override public Captcha getCaptcha(String id) throws CaptchaServiceException { try { Object captchaAndLocale = redisTemplate.opsForValue().get(id); return captchaAndLocale != null?((CaptchaAndLocale)captchaAndLocale).getCaptcha():null; } catch (Exception e) { logger.info("redisTemplate get({}) failure. error message {}",id,e.getMessage()); return null; } } @Override public Locale getLocale(String id) throws CaptchaServiceException { try { Object captchaAndLocale = redisTemplate.opsForValue().get(id); return captchaAndLocale != null?((CaptchaAndLocale)captchaAndLocale).getLocale():null; } catch (Exception e) { logger.info("redisTemplate get({}) failure. error message {}",id,e.getMessage()); return null; } } @Override public int getSize() { return 0; } @Override public Collection getKeys() { return null; } @Override public void empty() { } @Override public void initAndStart() { } @Override public void cleanAndShutdown() { } public RedisTemplate<String, Object> getRedisTemplate() { return redisTemplate; } public void setRedisTemplate(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } }
改造完成之后需要在cas-servlet.xml 引入新改造的类
上图中redisCaptchaStore的构造参数<constructor-arg index="0" value="600" />用于配置key过期时间
原验证码登录校验
查看DaAuthenticationViaFormAction.java,该类继承自 AuthenticationViaFormAction
可以看到在校验代码时 valid = captchaService.validateResponseForID(id,captcha_response).booleanValue();
传入从sessionId,
继续往下跟 AbstractCaptchaService.java 可以看到
这里点进this.store.getCaptcha(ID)可以看到
里面有两个继承自CaptchaStore 的类MapCaptchaStore 和刚才新增的用于存储和取出验证码的类 RedisCaptchaStore,此处校验通过之后会返回验证码校验结果true或false,同时执行this.store.removeCaptcha(ID); 删除session或者redis中存的验证码数据。
现验证码登录校验
首先需要修改login-webflow.xml文件的<view-state>标签内容,增加uuid属性值提交
同时修改用于接收提交参数的实体类UsernamePasswordverifyCodeCredential.java,增加参数uuid的get、set方法
再回到验证码登录校验类,更改原来的获取sessionId为通过Credentials获取uuid
后续实际校验验证码的内容无需更改,同原验证码登录校验。
总结:整体针对验证码放入redis的操作来看,只是改变了原验证码的CaptchaStore的实现类,改造实操相对简单,但是阅读原代码存储方式费力些。有了以上经验,那么后面改造LT的存储相对就简单一些了。
SSO系统LT存入redis
首先看下lt在登录页面中的位置,位于登录提交表单的隐藏域,
lt的作用简单来说就是为了应对登录用户点击退出后,在浏览器点击回退操作时,系统不会自动提交登录参数从而在操作人员无意识情况下再次登录系统。
原LT存储及验证
首先看GenerateLoginTicketAction.java 可以看到
lt生成之后通过WebUtils.putLoginTicket(context, loginTicket);调用放入了context.getFlowScope()中,
页面表单输入用户名密码验证码后提交到达 AuthenticationViaFormAction.java 可以看到
这个方法final String authoritativeLoginTicket = WebUtils.getLoginTicketFromFlowScope(context);会从flowScope中获取lt,通过与表单提交方法获取的lt 的final String providedLoginTicket = WebUtils.getLoginTicketFromRequest(context);进行equals比较,不相同则直接返回error。
现LT存储验证
首先需要给生成验证码方法引入redisTemplate,修改cas-servlet.xml配置文件
同时在lt提交认证类中也引入redisTemplate
改造后的生产lt的方法
改造后的表单提交校验lt的方法
通过以上即可以完成SSO系统验证码、LT更改存储位置及正常业务验证的方法。
补充内容(SSO系统补偿service)
现状分析
通过上述的改造后,再配合nginx无会话保持时两台机器测试单点登录,发现每次登录成功后均不能正常跳转到业务页面,而是跳转到如下页面
这又是什么原因呢?为了找到问题所在,重新切换回单台单点登录系统就能正常跳转到业务系统首页
分析问题其实还是出在nginx会话保持去掉后,两台机器之间轮询访问导致的。
继续回到SSO单点登录流程上找问题,查看login-webflow.xml,
可以看到在提交登录表单验证success后应进入sendTicketGrantingTicket,同时发现在提交表单验证的submit方法中
service此处不应为null,应为正确的需要跳转业务系统的地址。
继续回到sendTicketGrantingTicket,看到随后会执行serviceCheck
执行serviceCheck 时会判断flowScope.service != null 时走generateServiceTicket 如果为null,则会走viewGenericLoginSuccess,而如果执行到viewGenericLoginSuccess也就是上面我们看到的登录成功的页面,这个页面当然不是我们想要的,我们想要的是登录成功可以正常跳转到业务系统页面,那我们看一下GenerateServiceTicketAction 可以看到SSO系统会为当前service生成ST票据,而service正是我们的业务系统
后面需要做的就是解决login-webflow.xml中flowScope.service != null,从而让他执行到后面的generateServiceTicket为服务正确的生成ST票据完成登录授权
那么如何解决flowScope.service != null的问题呢,分析可知原来单台SSO系统,service是不会为空的,那么也就会正常执行到generateServiceTicket完成对服务授权ST票据,而多机器部署后,由于上面的改造并未考虑到service放入redis中,故而后续在失去nginx会话保持后,由于登录页面在A机器加载,此时service就会存在于A的context.getFlowScope(),而提交时可能提交到了B机器,此时通过
回去显然是获取不到service的,那么如果在A机器刷新登录页面时将service备份一份在redis中,而在登录表单提交请求到达B机器后,从redis中取出service,放入B机器的context.getFlowScope()中,那么两台机器都会拥有service,也就会完成后面对service的登录授权并分发ST票据了。
问题处理
基于上述分析,后面进行操作,修改cas-servlet.xml,在流程开始类initialFlowSetupAction中配置RedisTemplate模板
在流程开始类InitialFlowSetupAction.java中将service备用一份在redis中
在AuthenticationViaFormAction.java中的submit方法中当Service service = WebUtils.getService(context);为null时从redis中获取service并重新补偿进context.getFlowScope();
这样后面GenerateServiceTicketAction.java就会正常执行给业务系统授权ST票据信息,从而在登录完成及票据授权完成后可以跳转到正确的业务系统页面。