【vue + springboot】前后端分离项目接入cas单点登录系统
一、环境
cas服务端:4.0.0(这是一个比较老的maven版本,也可以选择新的gradle版本)
tomcat:8.5.99
前端:vue
后端:springboot(cas客户端)
二、cas服务端搭建
-
tomcat下载:https://dlcdn.apache.org/tomcat/tomcat-8/v8.5.99/bin/apache-tomcat-8.5.99-windows-x64.zip
-
cas下载:https://github.com/apereo/cas/releases/tag/v4.0.0
-
解压 cas-server-4.0.0-release.zip,在 cas-server-4.0.0\modules 目录下找到 cas-server-webapp-4.0.0.war
-
为方便访问,重命名 cas-server-webapp-4.0.0.war 为 cas.war
-
将 cas.war 放到 tomcat 的 webapps 目录下,启动tomcat
-
访问登录页,http://localhost:8080/cas/login 默认用户名:casuser 默认密码:Mellon
-
修改端口(可选)
- 修改tomcat端口,apache-tomcat-8.5.99\conf\server.xml
- 修改cas配置文件,apache-tomcat-8.5.99\webapps\cas\WEB-INF\cas.properties
- 修改tomcat端口,apache-tomcat-8.5.99\conf\server.xml
-
去除https认证(可选)
CAS默认使用的是https协议,如果使用https协议需要SSL安全证书(需向特定的机构申请和购买)- 修改cas的 WEB-INF/deployerConfigContext.xml
增加配置:p:requireSecure=“false”
- 修改cas的/WEB-INF/spring-configuration/ticketGrantingTicketCookieGenerator.xml
修改配置:p:cookieSecure=“false”
- 修改cas的 WEB-INF/deployerConfigContext.xml
-
新增或修改默认用户
初始用户、密码配置在 cas\WEB-INF\deployerConfigContext.xml
-
配置mysql,可以参考 https://blog.csdn.net/zh350229319/article/details/50510209
三、跨域问题解决思路
- 后端重写鉴权失败重定向策略,返回特定状态码给前端,由前端跳转登录页
- 前端跳转登录页,路由后面拼接 service 和 url
- service为登录成功回调客户端接口,由后端提供
- url为登录成功后跳转前端页面路由,由前端提供
- 登录成功,后端在回调接口中,重定向到前端url并拼接jsessionid
- 重定向到前端页面,前端判断地址栏中如果包含jessionid,就写入cookie
四、cas客户端接入
- pom.xml新增maven依赖
<dependency> <groupId>net.unicon.cas</groupId> <artifactId>cas-client-autoconfig-support</artifactId> <version>2.3.0-GA</version> </dependency>
- SpringBoot启动类新增@EnableCasClient注解
import net.unicon.cas.client.configuration.EnableCasClient; @EnableCasClient public class SpringBootApplication { }
- 配置文件中新增cas客户端配置
# cas客户端配置 cas: server-login-url: http://{cas_server_ip}/cas/login server-url-prefix: http://{cas_server_ip}/cas client-host-url: http://{cas_client_ip}:${server.port} use-session: true # 单点登录成功回调cas客户端接口 cas-client-service: http://{cas_client_ip}:${server.port}/user/check_sso # 单点登出地址 cas-server-logout-url: ${cas.server-url-prefix}/logout
- 新增cas配置类
@Slf4j @Getter @Configuration public class CasConfig { @Value("${cas.server-url-prefix}") private String serverUrlPrefix; @Value("${cas.server-login-url}") private String serverLoginUrl; @Value("${cas.client-host-url}") private String clientHostUrl; @Value("${cas-client-service}") private String clientService; @Value("${cas-server-logout-url}") private String serverLogoutUrl; /** * 单点登录认证不通过,返回特定错误码,由前端跳转单点登录页,避免跨域问题 */ public Result<Map<String, String>> redirectLoginPage(CommonError commonError) { Result<Map<String, String>> result = new Result<>(); result.setCode(commonError.getCode()); result.setMsg(commonError.getMsg()); Map<String, String> map = new HashMap<>(); map.put("ssoLoginUrl", serverLoginUrl); map.put("service", clientService); result.setData(map); return result; } /** * @see IgnoreSSLValidateFilter * 作用:自定义filter用于忽略ssl认证 */ @Bean @SuppressWarnings({"rawtypes", "unchecked"}) public FilterRegistrationBean ignoreSSLValidateFilter() { FilterRegistrationBean registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(new IgnoreSSLValidateFilter()); registrationBean.addUrlPatterns("/*"); registrationBean.setName("ignoreSSLValidateFilter"); return registrationBean; } /** * @see SingleSignOutHttpSessionListener * 作用:单点登出监听器 */ @Bean @SuppressWarnings({"rawtypes", "unchecked"}) public ServletListenerRegistrationBean singleSignOutHttpSessionListener() { ServletListenerRegistrationBean registrationBean = new ServletListenerRegistrationBean(); registrationBean.setListener(new SingleSignOutHttpSessionListener()); registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); return registrationBean; } /** * @see Cas30ProxyReceivingTicketValidationFilter * 作用:验证ticket */ @Bean @SuppressWarnings({"rawtypes", "unchecked"}) public FilterRegistrationBean casTicket() { FilterRegistrationBean registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(new Cas30ProxyReceivingTicketValidationFilter()); registrationBean.addUrlPatterns("/*"); registrationBean.addInitParameter("encodeServiceUrl", "false"); registrationBean.addInitParameter("casServerUrlPrefix", serverUrlPrefix); registrationBean.addInitParameter("serverName", clientHostUrl); registrationBean.addInitParameter("useSession", "true"); registrationBean.setOrder(1); return registrationBean; } /** * @see AuthenticationFilter * 作用:鉴权 */ @Bean @SuppressWarnings({"rawtypes", "unchecked"}) public FilterRegistrationBean casAuth() { FilterRegistrationBean registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(new AuthenticationFilter()); registrationBean.addUrlPatterns("/*"); registrationBean.addInitParameter("encodeServiceUrl", "false"); // 自定义重定向策略,为解决前后端跨域问题 registrationBean.addInitParameter("authenticationRedirectStrategyClass", CustomAuthRedirectStrategy.class.getName()); registrationBean.addInitParameter("casServerLoginUrl", serverLoginUrl); registrationBean.addInitParameter("serverName", clientHostUrl); // 忽略的url,"|"分隔多个url "/logout/success|/index" String ignorePattern = "/swagger-ui.html|" + "/organization/generate_link_id|" + "/user/regist_user|" + "/user/join_org"; log.info("sso auth ignorePattern: {}", ignorePattern); registrationBean.addInitParameter("ignorePattern", ignorePattern); registrationBean.setOrder(2); return registrationBean; } /** * @see SingleSignOutFilter * 作用:单点登出 */ @Bean @SuppressWarnings({"rawtypes", "unchecked"}) public FilterRegistrationBean casLogout() { FilterRegistrationBean registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(new SingleSignOutFilter()); registrationBean.addUrlPatterns("/*"); registrationBean.addInitParameter("casServerUrlPrefix", serverUrlPrefix); registrationBean.setName("CAS Single Sign Out Filter"); registrationBean.setOrder(3); return registrationBean; } /** * @see HttpServletRequestWrapperFilter * 作用:包装HttpServletRequest以达到增强功能 * 使用场景:例如添加额外的请求参数、修改请求头信息、重写请求方法等 */ @Bean @SuppressWarnings({"rawtypes", "unchecked"}) public FilterRegistrationBean httpServletRequestWrapperFilter() { FilterRegistrationBean registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(new HttpServletRequestWrapperFilter()); registrationBean.addUrlPatterns("/*"); registrationBean.setOrder(4); return registrationBean; } /** * @see AssertionThreadLocalFilter * 作用:将Assert信息放入ThreadLocal中 * 使用场景:支持以下方式获取用户名:AssertionHolder.getAssertion().getPrincipal().getName() */ @Bean @SuppressWarnings({"rawtypes", "unchecked"}) public FilterRegistrationBean assertionThreadLocalFilter() { FilterRegistrationBean registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(new AssertionThreadLocalFilter()); registrationBean.addUrlPatterns("/*"); registrationBean.setOrder(5); return registrationBean; } }
- 自定义cas鉴权失败重定向策略
public class CustomAuthRedirectStrategy implements AuthenticationRedirectStrategy { @Override public void redirect(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, String s) throws IOException { httpServletResponse.setCharacterEncoding("utf-8"); httpServletResponse.setContentType("application/json; charset=utf-8"); CasConfig casConfig = SpringContextUtil.getBean(CasConfig.class); Result<Map<String, String>> result = casConfig.redirectLoginPage(CommonError.SSO_AUTH_FAILURE); httpServletResponse.getWriter().write(JsonUtils.toJson(result)); } }
- 自定义filter忽略ssl认证(可选)
@Slf4j public class IgnoreSSLValidateFilter implements Filter { static { // 忽略ssl认证 try { TrustManager[] trustAllCerts = {new X509TrustManager() { public X509Certificate[] getAcceptedIssuers() { return null; } public void checkClientTrusted(X509Certificate[] arg0, String arg1) { } public void checkServerTrusted(X509Certificate[] arg0, String arg1) { } }}; SSLContext sc = SSLContext.getInstance("SSL"); sc.init(null, trustAllCerts, new SecureRandom()); HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); HostnameVerifier allHostsValid = (hostname, session) -> true; HttpsURLConnection.setDefaultHostnameVerifier(allHostsValid); } catch (NoSuchAlgorithmException | KeyManagementException e) { log.error("ignore ssl auth error", e); } } public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { chain.doFilter(request, response); } public void destroy() { } public void init(FilterConfig config) throws ServletException { } }
- 单点登录成功回调cas客户端接口
@Controller @RequestMapping("/user") @Api(value = "用户管理", tags = "用户管理") @Slf4j public class UserController { @Resource private CasConfig casConfig; @WebLog @ApiOperation("检查登录状态") @GetMapping(value = "/check_sso") public Object ssoCheck(HttpServletRequest request, @RequestParam String url) { HttpSession session = request.getSession(); Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION); if (assertion == null) { log.info("sso validate failed"); throw new ServiceException(CommonError.SSO_AUTH_FAILURE); } String username = assertion.getPrincipal().getName(); if (StringUtils.isBlank(username)) { log.info("sso validate failed"); throw new ServiceException(CommonError.SSO_AUTH_FAILURE); } String sessionId = session.getId(); log.info("sso validate successful, username: {}, sssionid: {}", username, sessionId); if (url.contains("jsessionid")) { // 如果url中已经存在jsessionid,则去掉 return new RedirectView(url.substring(0, url.indexOf("jsessionid") - 1)); } return new RedirectView(url + "?jsessionid=" + sessionId); } @WebLog @ResponseBody @ApiOperation("登录") @PostMapping("/auto_login") public Result<?> autoLogin(HttpServletRequest request) { HttpSession session = request.getSession(); Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION); if (assertion == null || StringUtils.isBlank(assertion.getPrincipal().getName())) { throw new ServiceException(CommonError.SSO_AUTH_FAILURE); } return Result.ok(userService.autoLogin(assertion.getPrincipal().getName())); } @WebLog @ResponseBody @ApiOperation("退出登录") @PostMapping("/logout") public Result<?> logout(HttpSession session) { session.invalidate(); Map<String, String> map = new HashMap<>(); map.put("logout", casConfig.getServerLogoutUrl()); return Result.ok(map); } }
- 前端核心代码:
五、问题记录
-
cas客户端报https认证失败
报错信息:logback- 2024-03-11 16:21:06:816 traceId:NONE [http-nio-8080-exec-2] org.jasig.cas.client.util.CommonUtils:442[getResponseFromServer] ERROR: SSL error getting response from host: 10.5.216.10 : Error Message: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target javax.net.ssl.SSLHandshakeException: PKIX path building failed: sun.security.provider .certpath.SunCertPathBuilderException: unable to find valid certification path to requested target at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:131) at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:376) at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:319) at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:314) at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.checkServerCerts(CertificateMessage.java:654) at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.onCertificate(CertificateMessage.java:473) at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.consume(CertificateMessage.java:369) at java.base/sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:392)
报错原因:cas服务端开启了https认证
解决方案:自定义filter忽略ssl认证 -
前端跨域问题
报错信息:
报错原因:和cas过滤器顺序有关系
解决方案:保证下面这三个cas过滤器的顺序
Cas30ProxyReceivingTicketValidationFilter、AuthenticationFilter、SingleSignOutFilter -
service不匹配问题
报错信息:org.jasig.cas.client.validation.TicketValidationException: Ticket 'ST-48-7f5W5TbK6UxvgM27ThUx-cas01.example.org' does not match supplied service. The original service was 'http://10.1.16.252:8080/user/check_sso?redirectUrl=http://10.1.16.158:8080' and the supplied service was 'http://10.1.16.252:8080/user/check_sso?redirectUrl=http%3A%2F%2F10.1.16.158%3A8080'. at org.jasig.cas.client.validation.Cas20ServiceTicketValidator.parseResponseFromServer(Cas20ServiceTicketValidator.java:84) at org.jasig.cas.client.validation.AbstractUrlBasedTicketValidator.validate(AbstractUrlBasedTicketValidator.java:198) at org.jasig.cas.client.validation.AbstractTicketValidationFilter.doFilter(AbstractTicketValidationFilter.java:204) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ....
报错原因:
cas服务端记录的service是…/user/check_sso?redirectUrl=http://10.1.16.158:8080,
验证ticket时传给cas服务端的service是…/user/check_sso?redirectUrl=http%3A%2F%2F10.1.16.158%3A8080,
明显是客户端在ticket验证时对 service 进行了url-encoding,导致ticket验证不通过。解决方案:Cas30ProxyReceivingTicketValidationFilter 设置 encodeServiceUrl 为 false
六、原理分析
-
核心票据
- TGC(Ticket Granting Cookie):
授权的票据证明,由 CAS Server 通过 SSL 方式发送给终端用户,存放用户身份认证凭证的Cookie,在浏览器和CAS Server间通讯时使用,并且只能基于安全通道传输(Https),是CAS Server用来明确用户身份的凭证。
- TGT(Ticket Granting Ticket):
TGT是CAS为用户签发的登录票据,拥有了TGT,用户就可以证明自己在CAS成功登录过。TGT封装了Cookie值以及此Cookie值对应的用户信息。用户在CAS认证成功后,CAS生成Cookie(叫TGC),写入浏览器,同时生成一个TGT对象,放入自己的缓存,TGT对象的ID就是Cookie的值。当HTTP再次请求到来时,如果传过来的有CAS生成的Cookie,则CAS以此Cookie值为key查询缓存中有无TGT ,如果有的话,则说明用户之前登录过,如果没有,则用户需要重新登录。
- ST(Service Ticket):
ST是CAS为用户签发的访问某一service的票据。用户访问service时,service发现用户没有ST,则要求用户去CAS获取ST。用户向CAS发出获取ST的请求,如果用户的请求中包含Cookie,则CAS会以此Cookie值为key查询缓存中有无TGT,如果存在TGT,则用此TGT签发一个ST,返回给用户。用户凭借ST去访问service,service拿ST去CAS验证,验证通过后,允许用户访问资源。
- TGC(Ticket Granting Cookie):
-
单点登录过程
-
打开浏览器,访问client1:http://10.5.216.121/cc/index.php,未登录状态自动跳转登录页。
-
输入用户名、密码登录后,cas-server回调client service接口,响应头中携带TGC、ST存入浏览器。
-
接着访问client2:http://10.5.218.101
client2携带浏览器TGC请求cas-server,cas-server根据TGC(SessionID)去查找是否有对应的TGT(Session),
如果有,用户就不需要再登录(SSO的体现),而cas-server会根据TGT签发新的ST,并重定向到client2
携带JsessionId重定向到目标页面:
-