基于Spring boot的CAS单点登录项目改造

背景

最近换了份工作,入职第一个任务就是把一个Spring boot项目改造成SSO单点登录,之前没有接触过单点登录,上来就项目改造真的是压力山大啊,用了五天连摸索带学习总算改成了,目前来看应该没啥问题。现在特意来此记录下学习实践过程。

项目改动之前结构大致就是springboot做基础,shiro做权限管理,其他的都无关紧要了。

简介

1. 什么是SSO

引用一段百度百科的专业解释:

单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统

2. 什么是CAS

百度百科的解释为:

CAS是Central Authentication Service的缩写,中央认证服务,一种独立开放指令协议。CAS 是 耶鲁大学(Yale University)发起的一个开源项目,旨在为 Web 应用系统提供一种可靠的单点登录方法,CAS 在 2004 年 12 月正式成为 JA-SIG 的一个项目。

简单的来讲,CAS是实现单点登陆的一个外部服务,跟Eureka,Nacos那些注册中心有些类似,所有接入系统的登陆都由CAS服务端来进行验证并返回用户信息。

3. CAS单点登陆认证流程

借官网时序图一用
Web flow diagram
文字概括一下:

  • 用户首次访问(First Access)
  1. 当请求到达服务端时,服务端的认证框架(spring security、shiro等)对请求(一般情况下有个JSESSIONID)进行校验认证。
  2. 服务端校验后发现用户并未登录,则会将请求重定向到CAS服务器,并附带一个service参数存放客户端域名地址。
  3. 重定向到CAS服务端后,CAS服务端校验用户Session,发现用户并未在CAS服务端登陆过,则被引导进入CAS服务的登陆认证,用户通过一系列方式(取决于CAS服务器的认证方式,例如账号密码或者扫码登陆)进行登陆。
  4. 用户在CAS服务端登陆成功后,CAS服务端会在浏览器设置一个CASTGC的cookie(注意此cookie的位置在CAS服务器的域名下),并且重定向到之前第二步中service参数指定的服务端地址,其中带上了一个ticket参数。
  5. 服务端的过滤器拦截到service请求后会向CAS服务端进行Ticket校验,参数就是上一步中的ticket参数以及service参数。
  6. CAS服务端校验Ticket会返回一个XML报文,解析后拿到校验结果,校验成功就回重定向到之前的页面,并在Cookie中写入认证标识(JSESSIONID)。
  7. 客户端重新发送第一步中的请求,此时已附带上JSESSIONID至服务端。
  8. 服务端校验JSESSIONID后返回相对应的数据。
  • 用户第二次访问相同应用(Second Access To Same Application)
  1. 如果用户浏览器Cookie没被清除,第二次的请求中依然会附带JSESSIONID。
  2. 服务端校验JSESSIONID后返回逻辑数据。如果校验JSESSIONID失败则会按第一次请求的情况处理。
  • 用户第一次登陆CAS系统中的其他应用(First Access To Second Application)
  1. 服务端接到请求后进行校验,发现用户并未登陆信息,重定向到CAS服务端,参数中带有请求端的域名地址,另外还会附带之前第一次访问登陆成功后写入CAS服务器域名Cookie下的CASTGC。
  2. CAS服务端对CASTGC的值进行校验,成功后重定向service参数地址(服务端地址),并附带上Ticket校验参数。
  3. 服务端接收到CAS重定向地址的请求,获取其附带Ticket参数向CAS服务端进行校验,解析返回的XML报文,成功则重定向到最初页面,并在该域名下添加Cookie:MOD_AUTH_CAS_S。
  4. 客户端重新发起请求,附带Cookie参数,后端进行Cookie校验后执行业务逻辑并且返回响应数据。
4. 认证流程中的参数
  • service:服务端名称,上述流程图中跟CAS的服务端交互时都会带着一个Service的参数,这个参数是用来标识客户端的,存放的并不是回调地址,而是客户端的域名地址。
  • CASTGC:用户在CAS认证成功后,CAS生成cookie(即该参数)写入浏览器,目的是下次访问时可以通过该值从CAS服务端根据这个TGC查找与之对应的TGT。从而判断用户是否登录过了,是否需要展示登录页面。
  • TGT:Ticket Granted Ticket,TGT是CAS为用户签发的登录票据,可以生成服务票据(ST),当CASTGC被写入Cookie同时生成一个TGT对象,放在CAS服务端缓存,TGT对象的ID就是Cookie的值。
  • ST:Service Ticket,是由TGT签发的访问某个service的票据,用户凭借ST去访问service,service拿ST去CAS验证,验证通过后,允许用户访问资源。
  • JSESSIONID:服务端框架对Session的管理,对每个Session对话都会有一个对应的JSESSIONID。再对接过程中会遇到JSESSIONID的坑,下面介绍。

Windows环境下搭建CAS服务端

对SSO有了一定了解之后,开始部署环境(公司并没有CAS服务器,还要自己搭,真的太难了),由于没有多余的服务器,就只能在本地搭建了。下面是搭建CAS服务器流程:

  • 源码下载
  1. CAS 是耶鲁大学的一个开源项目,所以并没有安装包什么的,需要先将源码从GitHub上down下来(源码地址)。在这里插入图片描述
    因为我的项目是用的jdk1.8,而6.x的版本jdk要求都是11,所以选择了5.1的版本。
  2. 从GitHub下down下代码后在这里插入图片描述
    注意左边的目录结构可能不一样,那是我后边添加的。右边的README有介绍怎么启动CAS服务。
  3. 打开cmd,进到项目目录下,输入build.cmd help可以看到服务启动的顺序。在这里插入图片描述
  • 项目配置
  1. 在etc/cas/config目录下有一个services文件夹,还有一个cas.properties和log4j.xml。service文件夹下有个名字是HTTPSandIMAPS-10000001的json文件,点开样子如下图。在这里插入图片描述这个文件就是配置service的json,用于CAS进行服务管理,之前说到的service参数就是与此处有关,通过添加json文件实现对多个服务的管理,并不是随便一个服务都可以接入CAS的,具体json参数示意可自行百度,本文不做赘述。
  2. 经测试,services文件夹设置并无效果,cas.properties的配置是可以生效的。那么如何让services文件夹中新配置的json文件生效呢?扫过无数博客后筛选出来一个解决办法:修改目录结构。
    该项目中并没有java工程中的src目录,这个跟他的构建方式有关(overlay),在根目录下创建src目录结构,然后打包,这时services中的json文件就会被打在war包中。在这里插入图片描述
  3. cas服务端是默认Https的,本地测试还是得用http,所以需要在配置文件中将http的配置加上,两个位置:
    第一个,services文件下json文件的serviceId的正则修改,示例:
    "serviceId" : "^(https|imaps|http)://.*"
    
    第二个,application.properties 中配置
    server.ssl.enabled=false
    
  4. 正经服务的登陆账号密码都不是写死的,可以关联数据库或其他方式来进行登陆,如果写死账号密码启动时会有个警告,一般情况下需要将账号密码置空。在etc/config/cas.properties中配置:
    cas.authn.accept.users=admin::123456
    
  • 项目启动

打开cmd,进入项目所在位置,按之前build.cmd help中的启动流程按顺序启动,CAS的服务端算是起起来了。访问页面在这里插入图片描述

服务端改造

正题来了,现在要将之前的Spring boot + Shiro项目接入CAS。

  • 考虑接入shiro-cas(放弃)
    Shiro框架有自带的CAS接入包,但Shiro在1.3.x之后接入了pac4j,pac4j是一个支持多种支持多种协议的身份认证的Java客户端。所以之前的shiro-cas包的内容都被弃用了。可以通过接入cas-server-support-pac4j包来实现,由于时间紧,资料也不多,shiro接入cas就暂时被搁置,有时间再研究吧。
  • 接入Spring security的cas
    现在网上比较多的资料大部分都是Spring security接入的cas,讲道理这个项目里已经有一个Shiro,再接入Spring security就显得有些累赘,但时间紧任务重(试用期不敢懈怠),又是第一次搞,只能硬着头皮上了。
  1. 修改pom文件,添加security依赖和security-cas依赖,版本号跟着项目来
    	<dependency>
    		<groupId>org.springframework.security</groupId>
    		<artifactId>spring-security-cas</artifactId>
    		<version>4.2.5.RELEASE</version>
    	</dependency>
    	<dependency>
    		<groupId>org.springframework.boot</groupId>
    		<artifactId>spring-boot-starter-security</artifactId>
    	</dependency>
    
  2. 通过注解 @EnableWebSecurity 启用Spring security
  3. 采用Java配置的方式来配置CAS客户端代码

    SecurityCasConfig.java

      该类为Securtiy通用配置类,继承自WebSecurityConfigurerAdapter适配器

    @Configuration
    @Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
    public class SecurityCasConfig extends WebSecurityConfigurerAdapter {
    
      	@Autowired
      	private SystemConfig config;
    
      	@Override
    	protected void configure(HttpSecurity http) throws Exception {
    		// 解除页面嵌套的安全限制
    		http.headers().frameOptions().disable();
    
    	    // 关闭CSRF跨域保护
    	    http.csrf().disable();
    	
    	    // 请求认证处理
    	    http.authorizeRequests()
    	        // 白名单
    	        .antMatchers(config.getCasExcludePath().split(",")).permitAll()
    	        // 除了白名单其他请求都要进行认证
    	        .anyRequest().authenticated()
    	        // logout不用认证
    	        .and().logout().permitAll()
    	        // 登陆配置
    	        .and().formLogin();
    	
    	    // 配置CAS过滤器及认证入口
    		http.exceptionHandling().authenticationEntryPoint(casAuthenticationEntryPoint())
    	        .and().addFilter(casAuthenticationFilter())
    	        .addFilterBefore(casLogoutFilter(), LogoutFilter.class)
    	        .addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class);
    	
    	  }
    
      	@Bean
      	public ServiceProperties serviceProperties() {
        	ServiceProperties sp = new ServiceProperties();
        	// cas登录默认回跳地址
        	sp.setService(config.getCasServiceAddress() + config.getCasServiceLoginAddress());
       		// 应用敏感
        	sp.setSendRenew(false);
        	// 是否对未拥有ticket的访问进行验证
        	sp.setAuthenticateAllArtifacts(true);
        	return sp;
      	}
    
      	/**
       	* 认证的入口
       	* 此处的MyCasAuthenticationEntryPoint是模仿CasAuthenticationEntryPoint实现了AuthenticationEntryPoint和InitializingBean接口
       	*/
      	@Bean
      	public MyCasAuthenticationEntryPoint casAuthenticationEntryPoint() {
        	MyCasAuthenticationEntryPoint casAuthenticationEntryPoint = new MyCasAuthenticationEntryPoint();
        	casAuthenticationEntryPoint.setLoginUrl(config.getCasServerLoginAddress());
        	casAuthenticationEntryPoint.setServiceProperties(serviceProperties());
        	casAuthenticationEntryPoint.setRequestCache(requestCache());
        	return casAuthenticationEntryPoint;
      	}
    
      	/**
       	* CAS认证过滤器
       	*/
      	@Bean
      	public CasAuthenticationFilter casAuthenticationFilter() throws Exception {
        	CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter();
        	casAuthenticationFilter.setAuthenticationManager(authenticationManager());
        	casAuthenticationFilter.setFilterProcessesUrl(config.getCasServiceLoginAddress());
    	//    casAuthenticationFilter.setRememberMeServices(nullRememberMeServicesPlus);
        	casAuthenticationFilter
            	.setAuthenticationSuccessHandler(refererRedirectionAuthenticationSuccessHandler());
        	return casAuthenticationFilter;
      	}
    
      	@Bean
      	public MySavedRequestAwareAuthenticationSuccessHandler refererRedirectionAuthenticationSuccessHandler() {
        	MySavedRequestAwareAuthenticationSuccessHandler successHandler = new MySavedRequestAwareAuthenticationSuccessHandler();
    	//    successHandler.setDefaultTargetUrl("/cas.html");
        	successHandler.setTargetUrlParameter("target-cas-url");
        	return successHandler;
      	}
    
      	@Bean(name = "casRequestCache")
      	public RequestCache requestCache() {
        	return new HttpSessionRequestCache();
      	}
    
      	@Bean
      	public Cas20ServiceTicketValidator cas20ServiceTicketValidator() {
        	return new Cas20ServiceTicketValidator(config.getCasServerAddress());
      	}
    
      	/**
       	* cas 认证 Provider
       	*/
      	@Bean
      	public CasAuthenticationProvider casAuthenticationProvider() {
        	CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider();
        	casAuthenticationProvider.setAuthenticationUserDetailsService(customUserDetailsService());
        	//casAuthenticationProvider.setUserDetailsService(customUserDetailsService()); //这里只是接口类型,实现的接口不一样,都可以的。
        	casAuthenticationProvider.setServiceProperties(serviceProperties());
        	casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator());
        	casAuthenticationProvider.setKey("casAuthenticationProviderKey");
        	return casAuthenticationProvider;
      	}
    
      	/**
       	* 用户自定义的AuthenticationUserDetailsService
       	*/
      	@Bean
      	public AuthenticationUserDetailsService<CasAssertionAuthenticationToken> customUserDetailsService() {
        	return new CustomUserDetailsService();
      	}
    
      	/**
       	* 单点登出过滤器
       	*/
      	@Bean
      	public SingleSignOutFilter singleSignOutFilter() {
        	SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter();
        	singleSignOutFilter.setCasServerUrlPrefix(config.getCasServerAddress());
        	singleSignOutFilter.setIgnoreInitConfiguration(true);
        	return singleSignOutFilter;
      	}
    
      	/**
       	* 请求单点退出过滤器
       	*/
      	@Bean
      	public LogoutFilter casLogoutFilter() {
        	LogoutFilter logoutFilter = new LogoutFilter(config.getCasServerLogoutAddress(),
            new SecurityContextLogoutHandler());
        	logoutFilter.setFilterProcessesUrl(config.getCasServerLogout());
        	return logoutFilter;
      	}
    }
    
    
     上面这段配置中有一些用到了框架自带的类,还有一些是自定义的配置,下面来介绍这些自定义类。
    

    MyCasAuthenticationEntryPoint.java

      该类是对SpringSecurity中的CasAuthenticationEntryPoint.java进行的扩展。该类实现了AuthenticationEntryPoint接口,当ExceptionTranslationFilter截获AuthenticationException
    或者AccessDeniedException异常时(即登陆认证异常或者拒绝访问时),就会调用AuthenticationEntryPoint的commence方法。

    @Slf4j
    public class MyCasAuthenticationEntryPoint implements AuthenticationEntryPoint,
    	InitializingBean {
    
    	private ServiceProperties serviceProperties;
    	private String loginUrl;
    	private boolean encodeServiceUrlWithSessionId = true;
    	private RequestCache requestCache;
    
    
    	@Override
    	public void afterPropertiesSet() throws Exception {
    		Assert.hasLength(this.loginUrl, "loginUrl must be specified");
    		Assert.notNull(this.serviceProperties, "serviceProperties must be specified");
    		Assert.notNull(this.serviceProperties.getService(),
        		"serviceProperties.getService() cannot be null.");
    	}
    
    	@Override
    	public void commence(HttpServletRequest request, HttpServletResponse response,
      		AuthenticationException authException) throws IOException {
    		String urlEncodedService = createServiceUrl(request, response);
    		String originUrl = MySavedRequestAwareAuthenticationSuccessHandler.getFullURL(request);
    		urlEncodedService += StringUtils.hasText(originUrl) ? "?target-cas-url=" + URLEncoder.encode(originUrl) : "";
    	    String redirectUrl = createRedirectUrl(urlEncodedService);
    		log.info("commence request:" + MySavedRequestAwareAuthenticationSuccessHandler.getFullURL(request));
    	    log.info("redirectUrl:" + redirectUrl);
    		preCommence(request, response);
    
    		response.sendRedirect(redirectUrl);
    	}
    
    	protected String createServiceUrl(final HttpServletRequest request,
      		final HttpServletResponse response) {
    		return CommonUtils.constructServiceUrl(null, response,
        		this.serviceProperties.getService(), null,
        		this.serviceProperties.getArtifactParameter(),
        		this.encodeServiceUrlWithSessionId);
    	}
    
    	protected String createRedirectUrl(final String serviceUrl) {
    		return CommonUtils.constructRedirectUrl(this.loginUrl,
        		this.serviceProperties.getServiceParameter(), serviceUrl,
        		this.serviceProperties.isSendRenew(), false);
    	}
    
    	 protected void preCommence(final HttpServletRequest request,
     		 final HttpServletResponse response) {
    
    	}
    
    	public final String getLoginUrl() {
    		return this.loginUrl;
    	}
    
    	public final ServiceProperties getServiceProperties() {
    		return this.serviceProperties;
    	}
    
    	public final void setLoginUrl(final String loginUrl) {
    		this.loginUrl = loginUrl;
    	}
    
    	public final void setServiceProperties(final ServiceProperties serviceProperties) {
    		this.serviceProperties = serviceProperties;
    	}
    
    	public final void setEncodeServiceUrlWithSessionId(
      		final boolean encodeServiceUrlWithSessionId) {
    		this.encodeServiceUrlWithSessionId = encodeServiceUrlWithSessionId;
    	}
    
    	protected boolean getEncodeServiceUrlWithSessionId() {
    		return this.encodeServiceUrlWithSessionId;
    	}
    
    	public void setRequestCache(RequestCache requestCache) {
    		this.requestCache = requestCache;
    	}
    } 
    
  • 0
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值