背景
最近换了份工作,入职第一个任务就是把一个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单点登陆认证流程
借官网时序图一用
文字概括一下:
- 当请求到达服务端时,服务端的认证框架(spring security、shiro等)对请求(一般情况下有个JSESSIONID)进行校验认证。
- 服务端校验后发现用户并未登录,则会将请求重定向到CAS服务器,并附带一个service参数存放客户端域名地址。
- 重定向到CAS服务端后,CAS服务端校验用户Session,发现用户并未在CAS服务端登陆过,则被引导进入CAS服务的登陆认证,用户通过一系列方式(取决于CAS服务器的认证方式,例如账号密码或者扫码登陆)进行登陆。
- 用户在CAS服务端登陆成功后,CAS服务端会在浏览器设置一个CASTGC的cookie(注意此cookie的位置在CAS服务器的域名下),并且重定向到之前第二步中service参数指定的服务端地址,其中带上了一个ticket参数。
- 服务端的过滤器拦截到service请求后会向CAS服务端进行Ticket校验,参数就是上一步中的ticket参数以及service参数。
- CAS服务端校验Ticket会返回一个XML报文,解析后拿到校验结果,校验成功就回重定向到之前的页面,并在Cookie中写入认证标识(JSESSIONID)。
- 客户端重新发送第一步中的请求,此时已附带上JSESSIONID至服务端。
- 服务端校验JSESSIONID后返回相对应的数据。
- 如果用户浏览器Cookie没被清除,第二次的请求中依然会附带JSESSIONID。
- 服务端校验JSESSIONID后返回逻辑数据。如果校验JSESSIONID失败则会按第一次请求的情况处理。
- 服务端接到请求后进行校验,发现用户并未登陆信息,重定向到CAS服务端,参数中带有请求端的域名地址,另外还会附带之前第一次访问登陆成功后写入CAS服务器域名Cookie下的CASTGC。
- CAS服务端对CASTGC的值进行校验,成功后重定向service参数地址(服务端地址),并附带上Ticket校验参数。
- 服务端接收到CAS重定向地址的请求,获取其附带Ticket参数向CAS服务端进行校验,解析返回的XML报文,成功则重定向到最初页面,并在该域名下添加Cookie:MOD_AUTH_CAS_S。
- 客户端重新发起请求,附带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服务器流程:
- CAS 是耶鲁大学的一个开源项目,所以并没有安装包什么的,需要先将源码从GitHub上down下来(源码地址)。
因为我的项目是用的jdk1.8,而6.x的版本jdk要求都是11,所以选择了5.1的版本。 - 从GitHub下down下代码后
注意左边的目录结构可能不一样,那是我后边添加的。右边的README有介绍怎么启动CAS服务。 - 打开cmd,进到项目目录下,输入build.cmd help可以看到服务启动的顺序。
- 在etc/cas/config目录下有一个services文件夹,还有一个cas.properties和log4j.xml。service文件夹下有个名字是HTTPSandIMAPS-10000001的json文件,点开样子如下图。
这个文件就是配置service的json,用于CAS进行服务管理,之前说到的service参数就是与此处有关,通过添加json文件实现对多个服务的管理,并不是随便一个服务都可以接入CAS的,具体json参数示意可自行百度,本文不做赘述。
- 经测试,services文件夹设置并无效果,cas.properties的配置是可以生效的。那么如何让services文件夹中新配置的json文件生效呢?扫过无数博客后筛选出来一个解决办法:修改目录结构。
该项目中并没有java工程中的src目录,这个跟他的构建方式有关(overlay),在根目录下创建src目录结构,然后打包,这时services中的json文件就会被打在war包中。 - cas服务端是默认Https的,本地测试还是得用http,所以需要在配置文件中将http的配置加上,两个位置:
第一个,services文件下json文件的serviceId的正则修改,示例:
第二个,application.properties 中配置"serviceId" : "^(https|imaps|http)://.*"
server.ssl.enabled=false
- 正经服务的登陆账号密码都不是写死的,可以关联数据库或其他方式来进行登陆,如果写死账号密码启动时会有个警告,一般情况下需要将账号密码置空。在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就显得有些累赘,但时间紧任务重(试用期不敢懈怠),又是第一次搞,只能硬着头皮上了。
-
修改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>
-
通过注解
@EnableWebSecurity
启用Spring security -
采用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; } }