SSO单点登录流程源码学习

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: 单点登录系统无状态应用,通过对SSO单点登录系统验证码、LT存入redis,及补偿service的操作更加深入的了解单点登录系统登录流程

应用背景

过去若是部署多台单点登录系统,会通过nginx配置做会话保持,从而保证不同客户端发起的登录请求会一直落在同一台机器,保证正常登录,nginx配置如图举例:

image.png

这里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()中放在页面隐藏域中

image.png

同时在casLoginView.jsp中放置隐藏域,放入uuid

image.png

同时更改原来的获取验证码方法,传入当前隐藏域的uuid用于生成验证码后存入redis的key

image.png

原验证码存储

下面再改造生成验证码的类CaptchaImageCreateController.java

image.png

进一步跟进生成验证码的方法 java.awt.image.BufferedImage challenge = jcaptchaService.getImageChallengeForID(captchaId, request.getLocale());

image.png

进去可以看到返回 (BufferedImage)this.getChallengeForID(ID, locale);

image.png

再继续向下跟可以看到 captcha = this.generateAndStoreCaptcha(locale, ID);

image.png

也就是当前这个方法captcha = this.generateAndStoreCaptcha(locale, ID);生成验证码和存储验证码的方法

查看当前类可以看到此处的this.store是CaptchaStore

image.png

那么回到cas-servlet.xml可以看到CaptchaStore用的是FastHashMapCaptchaStore.java

image.png

FastHashMapCaptchaStore又继承自MapCaptchaStore

image.png

打开MapCaptchaStore.java 可以看到存储验证码的方法,此处的this.store用的是FastHashMap,最终原来验证码是以hashMap的形式放在服务器session中的

image.png

这里还有另一种找到验证码存储位置的入口,比如

image.png

点进去之后继续跟进

image.png

image.png

image.png

最终也是会找到原来的验证码是通过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 引入新改造的类

image.png

上图中redisCaptchaStore的构造参数<constructor-arg index="0" value="600" />用于配置key过期时间

原验证码登录校验

查看DaAuthenticationViaFormAction.java,该类继承自 AuthenticationViaFormAction

image.png

可以看到在校验代码时 valid = captchaService.validateResponseForID(id,captcha_response).booleanValue();

传入从sessionId,

image.png

继续往下跟 AbstractCaptchaService.java 可以看到

image.png

这里点进this.store.getCaptcha(ID)可以看到

image.png

里面有两个继承自CaptchaStore 的类MapCaptchaStore 和刚才新增的用于存储和取出验证码的类 RedisCaptchaStore,此处校验通过之后会返回验证码校验结果true或false,同时执行this.store.removeCaptcha(ID); 删除session或者redis中存的验证码数据。

现验证码登录校验

首先需要修改login-webflow.xml文件的<view-state>标签内容,增加uuid属性值提交

image.png

同时修改用于接收提交参数的实体类UsernamePasswordverifyCodeCredential.java,增加参数uuid的get、set方法

image.png

再回到验证码登录校验类,更改原来的获取sessionId为通过Credentials获取uuid

image.png

后续实际校验验证码的内容无需更改,同原验证码登录校验。

总结:整体针对验证码放入redis的操作来看,只是改变了原验证码的CaptchaStore的实现类,改造实操相对简单,但是阅读原代码存储方式费力些。有了以上经验,那么后面改造LT的存储相对就简单一些了。

SSO系统LT存入redis

首先看下lt在登录页面中的位置,位于登录提交表单的隐藏域,

image.pnglt的作用简单来说就是为了应对登录用户点击退出后,在浏览器点击回退操作时,系统不会自动提交登录参数从而在操作人员无意识情况下再次登录系统。

原LT存储及验证

首先看GenerateLoginTicketAction.java 可以看到

image.png

lt生成之后通过WebUtils.putLoginTicket(context, loginTicket);调用放入了context.getFlowScope()中,

页面表单输入用户名密码验证码后提交到达 AuthenticationViaFormAction.java 可以看到

image.png

这个方法final String authoritativeLoginTicket = WebUtils.getLoginTicketFromFlowScope(context);会从flowScope中获取lt,通过与表单提交方法获取的lt 的final String providedLoginTicket = WebUtils.getLoginTicketFromRequest(context);进行equals比较,不相同则直接返回error。

现LT存储验证

首先需要给生成验证码方法引入redisTemplate,修改cas-servlet.xml配置文件

image.png

同时在lt提交认证类中也引入redisTemplate

image.png

改造后的生产lt的方法

image.png

改造后的表单提交校验lt的方法

image.png

通过以上即可以完成SSO系统验证码、LT更改存储位置及正常业务验证的方法。

补充内容(SSO系统补偿service)

现状分析

通过上述的改造后,再配合nginx无会话保持时两台机器测试单点登录,发现每次登录成功后均不能正常跳转到业务页面,而是跳转到如下页面

image.png

这又是什么原因呢?为了找到问题所在,重新切换回单台单点登录系统就能正常跳转到业务系统首页

32D017B6-92CF-4d97-9147-527B82908EDA.png

分析问题其实还是出在nginx会话保持去掉后,两台机器之间轮询访问导致的。

继续回到SSO单点登录流程上找问题,查看login-webflow.xml,

image.png可以看到在提交登录表单验证success后应进入sendTicketGrantingTicket,同时发现在提交表单验证的submit方法中

4683095E-8ECF-45e9-9EA2-9F48ABD5A067.png

service此处不应为null,应为正确的需要跳转业务系统的地址。

image.png

继续回到sendTicketGrantingTicket,看到随后会执行serviceCheck

image.png

执行serviceCheck 时会判断flowScope.service != null 时走generateServiceTicket 如果为null,则会走viewGenericLoginSuccess,而如果执行到viewGenericLoginSuccess也就是上面我们看到的登录成功的页面,这个页面当然不是我们想要的,我们想要的是登录成功可以正常跳转到业务系统页面,那我们看一下GenerateServiceTicketAction 可以看到SSO系统会为当前service生成ST票据,而service正是我们的业务系统

C3C4D2A2-B97B-4fd3-856A-ADBC5D611C3F.png

后面需要做的就是解决login-webflow.xml中flowScope.service != null,从而让他执行到后面的generateServiceTicket为服务正确的生成ST票据完成登录授权

image.png

那么如何解决flowScope.service != null的问题呢,分析可知原来单台SSO系统,service是不会为空的,那么也就会正常执行到generateServiceTicket完成对服务授权ST票据,而多机器部署后,由于上面的改造并未考虑到service放入redis中,故而后续在失去nginx会话保持后,由于登录页面在A机器加载,此时service就会存在于A的context.getFlowScope(),而提交时可能提交到了B机器,此时通过

image.png

image.png

回去显然是获取不到service的,那么如果在A机器刷新登录页面时将service备份一份在redis中,而在登录表单提交请求到达B机器后,从redis中取出service,放入B机器的context.getFlowScope()中,那么两台机器都会拥有service,也就会完成后面对service的登录授权并分发ST票据了。

问题处理

基于上述分析,后面进行操作,修改cas-servlet.xml,在流程开始类initialFlowSetupAction中配置RedisTemplate模板

image.png

在流程开始类InitialFlowSetupAction.java中将service备用一份在redis中

image.png

AuthenticationViaFormAction.java中的submit方法中当Service service = WebUtils.getService(context);为null时从redis中获取service并重新补偿进context.getFlowScope();

image.png

这样后面GenerateServiceTicketAction.java就会正常执行给业务系统授权ST票据信息,从而在登录完成及票据授权完成后可以跳转到正确的业务系统页面。










相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
6月前
|
NoSQL Redis
SSO单点登录核心原理
SSO单点登录核心原理
158 0
|
存储 安全 Java
OAuth2实现单点登录SSO完整教程,其实不难!(上)
OAuth2实现单点登录SSO完整教程,其实不难!
3419 1
OAuth2实现单点登录SSO完整教程,其实不难!(上)
|
4月前
|
安全 Java 数据安全/隐私保护
在Java项目中集成单点登录(SSO)方案
在Java项目中集成单点登录(SSO)方案
|
3月前
|
安全 Java UED
掌握SpringBoot单点登录精髓,单点登录是一种身份认证机制
【8月更文挑战第31天】单点登录(Single Sign-On,简称SSO)是一种身份认证机制,它允许用户只需在多个相互信任的应用系统中登录一次,即可访问所有系统,而无需重复输入用户名和密码。在微服务架构日益盛行的今天,SSO成为提升用户体验和系统安全性的重要手段。本文将详细介绍如何在SpringBoot中实现SSO,并附上示例代码。
74 0
|
6月前
|
存储 缓存
实现单点登录的方式
实现单点登录的方式
89 1
|
6月前
|
存储 缓存 数据安全/隐私保护
探索 SSO 的世界:简化登录流程的最佳实践(上)
探索 SSO 的世界:简化登录流程的最佳实践(上)
探索 SSO 的世界:简化登录流程的最佳实践(上)
|
安全 数据安全/隐私保护
单点登录(SSO)看这一篇就够了
单点登录(SSO)看这一篇就够了
1048 4
|
前端开发
淘东电商项目(33) -SSO单点登录(改造SSO认证服务登录界面)
淘东电商项目(33) -SSO单点登录(改造SSO认证服务登录界面)
77 0
|
Java Maven
淘东电商项目(32) -SSO单点登录(集成SSO认证服务)
淘东电商项目(32) -SSO单点登录(集成SSO认证服务)
79 0
|
存储 安全 Java
(流程图 + 代码)带你实现单点登陆SSO(一)
(流程图 + 代码)带你实现单点登陆SSO