开源一夏 | SSO 单点登录流程源码学习

应用背景
过去若是部署多台单点登录系统,会通过 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
改造完成之后需要在 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 票据信息,从而在登录完成及票据授权完成后可以跳转到正确的业务系统页面。
版权声明: 本文为 InfoQ 作者【六月的雨在infoQ】的原创文章。
原文链接:【http://xie.infoq.cn/article/8b938f847f49482d3bc9bbc65】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。









评论