Spring Boot 快速接入 Cas 认证
cas-client-support-springboot 是 Cas 官方提供的客户端 SDK,可快速的集成 Spring Boot 实现统一身份认证平台的集成!
一、快速接入
1、引入依赖
在项目依赖管理中引入 cas-client-support-springboot 依赖 :
Maven 依赖
<!-- https://mvnrepository.com/artifact/org.jasig.cas.client/cas-client-support-springboot -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-support-springboot</artifactId>
<version>3.6.4</version>
</dependency>
Gradle 依赖
// https://mvnrepository.com/artifact/org.jasig.cas.client/cas-client-support-springboot
implementation group: 'org.jasig.cas.client', name: 'cas-client-support-springboot', version: '3.6.4'
2、功能扩展
2.1、自定义 AntUrlPatternMatcherStrategy 使用基于Ant表达式的匹配策略
import org.jasig.cas.client.authentication.UrlPatternMatcherStrategy;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
public class AntUrlPatternMatcherStrategy implements UrlPatternMatcherStrategy {
/**
* Any number of these characters are considered delimiters between multiple
* context config paths in a single String value.
*/
public static String CONFIG_LOCATION_DELIMITERS = ",; \t\n";
private AntPathMatcher matcher = new AntPathMatcher();
private String[] patterns;
@Override
public boolean matches(String url) {
for (String pattern : patterns) {
if (matcher.match(pattern, url)) {
return true;
}
}
return false;
}
@Override
public void setPattern(String pattern) {
this.patterns = StringUtils.tokenizeToStringArray(pattern, CONFIG_LOCATION_DELIMITERS);
}
}
2.2、如果遇到SSL证书问题,需要需要信任请求域名,可自定义 HostnameVerifier(可选)
public class TrustAllHostnameVerifier implements HostnameVerifier {
public final static TrustAllHostnameVerifier DEFAULT = new TrustAllHostnameVerifier();
@Override
public boolean verify(String hostname, SSLSession session) {
return true;
}
}
2.3、自定义Cas客户端配置器,CustomCasClientConfigurer,解决配置项扩展问题
import org.jasig.cas.client.boot.configuration.CasClientConfigurer;
import org.jasig.cas.client.configuration.ConfigurationKeys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* cas-client-support-springboot 依赖提供了CAS客户端的自动配置,
* 当自动配置不满足需要时,可通过实现{@link CasClientConfigurer}接口来重写需要自定义的逻辑
*/
@Component
public class CustomCasClientConfigurer implements CasClientConfigurer {
/**
* CAS-protected client Ignore Pattern Path, E.g: /api/*,/auth/*
*/
@Value("${cas-ignore-pattern-path:}")
private String ignorePatternPath;
/**
* 配置认证过滤器,添加忽略参数,使 /cas/logout 登出提示页免登录
*/
@Override
@SuppressWarnings({ "rawtypes", "unchecked" })
public void configureAuthenticationFilter(final FilterRegistrationBean authenticationFilter) {
Map initParameters = authenticationFilter.getInitParameters();
initParameters.put(ConfigurationKeys.IGNORE_URL_PATTERN_TYPE.getName(), AntUrlPatternMatcherStrategy.class.getName());
initParameters.put(ConfigurationKeys.IGNORE_PATTERN.getName(), ignorePatternPath);
}
@Override
public void configureValidationFilter(FilterRegistrationBean validationFilter) {
Map initParameters = validationFilter.getInitParameters();
initParameters.put(ConfigurationKeys.HOSTNAME_VERIFIER.getName(), TrustAllHostnameVerifier.class.getName());
initParameters.put(ConfigurationKeys.IGNORE_PATTERN.getName(), ignorePatternPath);
}
}
二、Cas 对接示例(前后端不分离)
为完整是的演示Cas对接过程,这里会给出对接示例演示 Cas对接过程。
1、创建 CasAuthController 控制器,并编写 /cas/index
、/cas/login
、/cas/logout
、/cas/logoutPage
4个接口,用来调试 Cas 对接逻辑
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.MapUtils;
import org.jasig.cas.client.Protocol;
import org.jasig.cas.client.boot.configuration.CasClientConfigurationProperties;
import org.jasig.cas.client.util.AbstractCasFilter;
import org.jasig.cas.client.util.CommonUtils;
import org.jasig.cas.client.validation.Assertion;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.*;
@Api(tags = "Cas 单点登录模拟")
@RestController
@RequestMapping("cas")
@Slf4j
public class CasAuthController {
@Autowired
private CasClientConfigurationProperties casProperties;
/**
* 首页,需要登录
*/
@GetMapping("/index")
@ResponseBody
public Map<String, Object> index(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 1、从上下文中获取CAS认证信息
HttpSession session = request.getSession(false);
Assertion assertion = (Assertion) (session == null ? request
.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION) : session
.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION));
// 2、如果没有认证信息,重定向到登录接口
if (Objects.isNull(assertion)) {
// 2.1、重定向到登录接口
response.sendRedirect(casProperties.getClientHostUrl() + "/cas/login");
return Collections.emptyMap();
}
// 3、如果有认证信息,获取用户信息
Map<String, Object> userInfo = CasUtil.getUserAttributes();
Map<String, Object> result = new HashMap<>(3);
result.put("code", 200);
result.put("msg", MapUtils.getString(userInfo, "nickname", "xx") + ", 您已登录成功。");
result.put("data", "<a href=\"" + casProperties.getClientHostUrl() + "/cas/logout\">退出登录</a>");
return result;
}
/**
* 登录接口(未登录时会被重定向到CAS Server进行认证)
*/
@GetMapping("login")
@ApiOperation("Cas 单点登录")
public void login(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 1、从上下文中获取CAS认证信息
HttpSession session = request.getSession(false);
Assertion assertion = (Assertion) (session == null ? request
.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION) : session
.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION));
// 2、如果没有认证信息,重定向到CAS Server进行认证
if (Objects.isNull(assertion)) {
// 2.1、重定向到登录页面
String casSingleLoginUrl = CommonUtils.constructRedirectUrl(casProperties.getServerLoginUrl(),
Protocol.CAS2.getServiceParameterName(),
casProperties.getClientHostUrl() + "/cas/login",
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
response.sendRedirect(casSingleLoginUrl);
return;
}
// 3、重定向到主页
final String urlToRedirectTo = casProperties.getClientHostUrl() + "/cas/index";
if (log.isDebugEnabled()) {
log.debug("redirecting to \"" + urlToRedirectTo + "\"");
}
response.sendRedirect(urlToRedirectTo);
}
/**
* 退出登录,跳转登出提示页
*/
@GetMapping("/logout")
@ApiOperation("cas 单点登出")
public void logout(HttpServletRequest request, HttpServletResponse response, @RequestParam(required = false) String url) throws IOException {
// 1、获取当前本地会话
HttpSession session = request.getSession(false);
if (Objects.nonNull(session)) {
// 1.1、本地会话过期
session.invalidate();
}
// 2、构造CAS Server登出URL,其中service参数为本地登出回调地址
String casSingleLoginUrl = CommonUtils.constructRedirectUrl(casProperties.getServerUrlPrefix() + "/logout",
Protocol.CAS2.getServiceParameterName(),
casProperties.getClientHostUrl() + "/cas/logoutPage",
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
// 3、重定向到CAS Server登出
response.sendRedirect(casSingleLoginUrl);
}
/**
* 登出提示页,免登录(生产中应该是项目的登录页面地址)
*/
@GetMapping("/logoutPage")
@ResponseBody
public Map<String, Object> logoutPage(HttpServletResponse response) {
// The URL to the CAS Server Single login.
String casSingleLoginUrl = CommonUtils.constructRedirectUrl(casProperties.getServerLoginUrl(),
Protocol.CAS2.getServiceParameterName(),
casProperties.getClientHostUrl() + "/cas/login",
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
// The URL to the Server Single logout.
String casSingleLogoutUrl = CommonUtils.constructRedirectUrl(casProperties.getServerUrlPrefix() + "/logout",
Protocol.CAS2.getServiceParameterName(),
casSingleLoginUrl,
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
Map<String, Object> result = new HashMap<>(3);
result.put("code", 200);
result.put("msg", "您已退出登录成功。");
result.put("data", "<a href=\"" + casSingleLoginUrl + "\">去登录</a><br><br>"
+ "<a href=\"" + casSingleLogoutUrl + "\">全局退出登录</a>");
return result;
}
}
/cas/index
:模拟主页,该接口模拟登录主页,返回登出地址
/cas/login
:登录接口,常用于与Cas服务认证交互以及提供给应用中心
配置功能入口(这里地址也可以是前端地址,那就需要前端实现单点出逻辑)/cas/logout
:本地登出接口,该接口销毁本地会话后,会重定向到登出模拟页面接口;如果业务需要直接全局登出,可以直接重定向Cas服务的登出地址/cas/logoutPage
:登出模拟页面,使用接口的方式模拟登录页面,接口返回登录地址
与全局登出地址
2、项目配置
Yaml 配置
############################################################################################
###Cas 认证(CasClientConfigurationProperties)配置:
############################################################################################
# CAS-protected client Ignore Pattern Path E.g. /api/cas/**. Optional.
cas-ignore-pattern-path: '**/cas/index,**/cas/logout,**/cas/logoutPage,**/swagger-ui/*,**/webjars/*,**/swagger-resources/*,**/v2/api-docs'
# Cas Configuration
cas:
# CAS server URL E.g. https://example.com/cas or https://cas.example. Required.
server-url-prefix: https://example.com
# CAS server login URL E.g. https://example.com/cas/login or https://cas.example/login. Required.
server-login-url: ${cas.server-url-prefix}/login
# CAS-protected client application host URL E.g. https://myclient.example.com Required.
client-host-url: https://myclient.example.com
# Hostname Verifier to use for HTTPS connections. Defaults to DefaultHostnameVerifier.
#hostname-verifier: DefaultHostnameVerifier
# SslConfig for HTTPS connections.
#ssl-config-file: classpath:conf/cas/ssl.properties
# ValidationType the CAS protocol validation type. Defaults to CAS3 if not explicitly set.
validation-type: CAS3
# Validation filter redirectAfterValidation. Defaults to true.
redirect-after-validation: false
# Whether to receive the single logout request from cas server.
single-logout:
enabled: true
Properties 配置
# CAS-protected client Ignore Pattern Path E.g. /api/cas/**. Optional.
cas-ignore-pattern-path=**/cas/index,**/cas/logout,**/cas/logoutPage,**/swagger-ui/*,**/webjars/*,**/swagger-resources/*,**/v2/api-docs
# Cas Configuration
cas.server-url-prefix=https://example.com
cas.server-login-url=${cas.server-url-prefix}/login
cas.client-host-url=https://myclient.example.com
cas.validation-type=CAS3
cas.single-logout.enabled=true
cas.redirect-after-validation=false
这里特别注意
redirect-after-validation
需要设置为false
, 否则会造成第4步中的示例代码,在登录之后无法正常登录,总会被重定向到service
参数所指向的地址。
3、启动对象添加 @EnableCasClient
注解,访问调试接口验证对接情况
应用入口代码:
import org.jasig.cas.client.boot.configuration.EnableCasClient;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@EnableCasClient
public class CasAutoConfigApp {
public static void main(String[] args) {
SpringApplication.run(CasAutoConfigApp.class, args);
}
}
EnableCasClient 注解类引入了配置类 CasClientConfiguration, 配置类中做了以下几件事:
- casAuthenticationFilter() 创建了 认证过滤器
- casValidationFilter() 创建了 验证票据过滤器
- casHttpServletRequestWrapperFilter() 创建了请求对象的包装类
- casAssertionThreadLocalFilter() 创建了将 Assertion 放到 ThreadLocal 的过滤器,对于获取不到HttpRequest 请求对象的情况这很有用
- casSingleSignOutFilter() 创建了单点登出的过滤器
- casSingleSignOutListener() 创建单点登出的Listener,用于监听登出事件,清理内存中单点登录会话缓存
- SpringSecurityAssertionAutoConfiguration 兼容Spring Security的配置类
4、验证Cas认证接入情况
认证验证流程:
4.1、首先访问 /cas/index
, 比如:http://192.168.0.20:8081/cas/index
/cas/index
接口不被拦截,登录信息不存在,则重定向到本地登录接口/cas/login
/cas/login
接口是被拦截的,因当前未进行Cas登录,访问接口会进入DefaultAuthenticationRedirectStrategy
的redirect
方法,被重定向到认证中心登录界面
public final class DefaultAuthenticationRedirectStrategy implements AuthenticationRedirectStrategy {
@Override
public void redirect(final HttpServletRequest request, final HttpServletResponse response,
final String potentialRedirectUrl) throws IOException {
response.sendRedirect(potentialRedirectUrl);
}
}
4.2、在统一身份认证平台完成登录验证
- 账号密码登录或其他方式登录,登录成功,重定向
/cas/login
接口 /cas/login
接口获取认证信息失败:跳转到 “统一身份认证平台” 的登录页面;获取认证信息成功:关联本地用户、生成Token、写 Cookie 信息- 完成本地用户关联后,重定向
/cas/index
“模拟主页”
4.3、“模拟主页”,返回如下内容:
{
"code": 200,
"msg": "您已登录成功。",
"data": "<a href=\"http://192.168.0.20:8081/cas/logout\">退出登录</a>"
}
内容中包含了登录成功提示
和 登出地址
,访问 登出地址
即可实现本地登出。
- 在 “模拟主页” 访问 “退出登录” 地址:http://client_host_url/cas/logout
- “业务系统/平台”会销毁本地会话,重定向到 /cas/logoutPage “登出模拟页”
4.4、“登出模拟页”,返回如下内容:
{
"code": 200,
"msg": "您已退出登录成功。",
"data": "<a href=\"http://192.168.3.27:31495/login?service=http%3A%2F%2F192.168.0.20%3A8081%2Fcas%2Flogin\">去登录</a><br><br><a href=\"http://192.168.3.27:31495/logout?service=http%3A%2F%2F192.168.3.27%3A31495%2Flogin%3Fservice%3Dhttp%253A%252F%252F192.168.0.20%253A8081%252Fcas%252Flogin\">全局退出登录</a>"
}
内容中包含了登出成功提示
、登录地址
、全局退出登录地址
。
- 在“登出模拟页” 访问 “全局登出地址”,会重新跳转到 “统一身份认证平台” 的登录页面
- 再次输入账号密码或使用其他方式登录登录成功,重定向回三方系统
/cas/login
接口 - 已经关联本地用户,重定向到 /cas/index “模拟主页”
三、Cas 对接示例(前后端分离)
在前后端分离的项目里面,需要对上面已经完成的对接逻辑进行一些调整。
cas/index
、cas/logoutPage
替换为前端地址,并去除模拟方法。
1、自定义重定向策略 CustomAuthRedirectStrategy
,解决前后端分离项目,Ajax 请求无法处理重定向问题
前后端分离项目,Cas的重定向无法跳转到 cas登录网址去,需要前端进行跳转,所以要重写AuthenticationRedirectStrategy,直接给前端返回401错误码.
CustomAuthRedirectStrategy.java
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import lombok.extern.slf4j.Slf4j;
import org.jasig.cas.client.Protocol;
import org.jasig.cas.client.authentication.AuthenticationRedirectStrategy;
import org.jasig.cas.client.boot.configuration.CasClientConfigurationProperties;
import org.jasig.cas.client.util.CommonUtils;
import org.springframework.http.HttpStatus;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 自定义跳转策略
*/
@Slf4j
public class CustomAuthRedirectStrategy implements AuthenticationRedirectStrategy {
public static final String DATE_LONGFORMAT = "yyyy-MM-dd HH:mm:ss";
private ObjectMapper objectMapper = JsonMapper.builder()
// 指定序列化输入的类型,类必须是非final修饰的,final修饰的类,比如String,Integer等会跑出异常
//.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL)
.defaultDateFormat(new SimpleDateFormat(DATE_LONGFORMAT))
// 指定要序列化的域,field,get和set,以及修饰符范围,ANY是都有包括private和public
.visibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY)
.serializationInclusion(JsonInclude.Include.NON_NULL).build();
@Override
public void redirect(HttpServletRequest request, HttpServletResponse response, String urlToRedirectTo) throws IOException {
CasClientConfigurationProperties casProperties = SpringBeanUtils.getApplicationContext().getBean(CasClientConfigurationProperties.class);
String origin = request.getHeader("Origin");
// 简单请求跨域,如果是跨域请求在响应头里面添加对应的Origin
if (StringUtils.hasText(origin)) {
response.addHeader("Access-Control-Allow-Origin", origin);
} else {
response.addHeader("Access-Control-Allow-Origin", "*");
}
// 非简单请求跨域
response.addHeader("Access-Control-Allow-Headers", "content-type");
// 允许跨域请求的方法
response.addHeader("Access-Control-Allow-Methods", "*");
// 携带cookie的跨域
response.addHeader("Access-Control-Allow-Credentials", "true");
if(WebUtils.isAjaxRequest(request)){
/**
* The URL to the CAS Server Single login.
*/
String casSingleLoginUrl = CommonUtils.constructRedirectUrl(casProperties.getServerLoginUrl(),
Protocol.CAS2.getServiceParameterName(),
casProperties.getClientHostUrl() + "/cas/login",
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
/**
* The URL to the CAS Server Single logout.
*/
String casSingleLogoutUrl = CommonUtils.constructRedirectUrl(casProperties.getServerUrlPrefix() + "/logout",
Protocol.CAS2.getServiceParameterName(),
casSingleLoginUrl,
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
if (log.isDebugEnabled()) {
log.debug("casSingleLoginUrl to \"" + casSingleLoginUrl + "\"");
log.debug("casSingleLogoutUrl to \"" + casSingleLogoutUrl + "\"");
}
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setHeader("content-type", "application/json;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
Map<String, Object> result = new HashMap<>();
result.put("code", HttpStatus.UNAUTHORIZED.value());
result.put("msg", "未授权,请先登录。");
Map<String, Object> data = new HashMap<>();
data.put("loginUrl", casSingleLoginUrl);
data.put("logoutUrl", casSingleLogoutUrl);
result.put("data", data);
objectMapper.writeValue(response.getOutputStream(), result);
} else {
response.sendRedirect(urlToRedirectTo);
}
}
}
2、修改 CustomCasClientConfigurer,添加自定义重定向策略配置
import org.jasig.cas.client.boot.configuration.CasClientConfigurer;
import org.jasig.cas.client.configuration.ConfigurationKeys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* cas-client-support-springboot 依赖提供了CAS客户端的自动配置,
* 当自动配置不满足需要时,可通过实现{@link CasClientConfigurer}接口来重写需要自定义的逻辑
*/
@Component
public class CustomCasClientConfigurer implements CasClientConfigurer {
/**
* CAS-protected client Ignore Pattern Path, E.g: /api/*,/auth/*
*/
@Value("${cas-ignore-pattern-path:}")
private String ignorePatternPath;
/**
* 配置认证过滤器,添加忽略参数,使 /cas/logout 登出提示页免登录
*/
@Override
@SuppressWarnings({ "rawtypes", "unchecked" })
public void configureAuthenticationFilter(final FilterRegistrationBean authenticationFilter) {
Map initParameters = authenticationFilter.getInitParameters();
initParameters.put(ConfigurationKeys.IGNORE_URL_PATTERN_TYPE.getName(), AntUrlPatternMatcherStrategy.class.getName());
initParameters.put(ConfigurationKeys.AUTHENTICATION_REDIRECT_STRATEGY_CLASS.getName(), CustomAuthRedirectStrategy.class.getName());
initParameters.put(ConfigurationKeys.IGNORE_PATTERN.getName(), ignorePatternPath);
}
@Override
public void configureValidationFilter(FilterRegistrationBean validationFilter) {
Map initParameters = validationFilter.getInitParameters();
initParameters.put(ConfigurationKeys.HOSTNAME_VERIFIER.getName(), TrustAllHostnameVerifier.class.getName());
initParameters.put(ConfigurationKeys.IGNORE_PATTERN.getName(), ignorePatternPath);
initParameters.put(ConfigurationKeys.EXCEPTION_ON_VALIDATION_FAILURE.getName(), Boolean.FALSE.toString());
}
}
3、在 CasAuthController 控制器,并添加 /cas/address
接口,用来前端获取跳转地址
/**
* 登录、注销地址
*/
@GetMapping("address")
@ApiOperation("获取登录配置")
public Map<String, Object> getCasAddress() {
Map<String, Object> result = new HashMap<>(2);
// The URL to the CAS Server Single login.
String casSingleLoginUrl = CommonUtils.constructRedirectUrl(casProperties.getServerLoginUrl(),
Protocol.CAS2.getServiceParameterName(),
casProperties.getClientHostUrl() + "/cas/login",
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
result.put("loginUrl", casSingleLoginUrl);
result.put("logoutUrl", casProperties.getClientHostUrl() + "/cas/logout");
return result;
}
4、修改 cas/login 认证成功后的重定向地址 :
添加 cas-client-front-url
配置参数:
Yaml 配置
# CAS-protected client front url E.g. http://localhost:8080. Required.
cas-client-front-url: http://192.168.0.20:8080
Properties 配置
# CAS-protected client front url E.g. http://localhost:8080. Required.
cas-client-front-url=http://192.168.0.20:8080
这里特别注意
cas-client-front-url
值前后端分离项目中的前端访问地址, 不要使用cas.client-front-url
, 否则会导致错误。
修改 CasAuthController 控制器代码, 添加 casClientFrontUrl 属性,修改 login 重定向逻辑
/**
* CAS-protected client front url E.g. http://localhost:8080. Required.
*/
@Value("${cas-client-front-url:}")
private String casClientFrontUrl;
@GetMapping("login")
@ApiOperation("Cas 单点登录")
public void login(HttpServletRequest request, HttpServletResponse response) throws IOException {
String origin = request.getHeader("Origin");
// 简单请求跨域,如果是跨域请求在响应头里面添加对应的Origin
if (StringUtils.hasText(origin)) {
response.addHeader("Access-Control-Allow-Origin", origin);
} else {
response.addHeader("Access-Control-Allow-Origin", "*");
}
// 非简单请求跨域
response.addHeader("Access-Control-Allow-Headers", "content-type");
// 允许跨域请求的方法
response.addHeader("Access-Control-Allow-Methods", "*");
// 携带cookie的跨域
response.addHeader("Access-Control-Allow-Credentials", "true");
// 1、从上下文中获取CAS认证信息
HttpSession session = request.getSession(false);
Assertion assertion = (Assertion) (session == null ? request
.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION) : session
.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION));
// 2、如果没有认证信息,重定向到CAS Server进行认证
if (Objects.isNull(assertion)) {
// 2.1、重定向到登录页面
String casSingleLoginUrl = CommonUtils.constructRedirectUrl(casProperties.getServerLoginUrl(),
Protocol.CAS2.getServiceParameterName(),
casProperties.getClientHostUrl() + "/cas/login",
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
response.sendRedirect(casSingleLoginUrl);
return;
}
// 3、如果有认证信息,获取用户信息
Long userId = CasUtil.getUserAccountId();
Map<String, Object> userInfo = CasUtil.getUserAttributes();
// 4、手动设置Cookie(前端地址与后端接口在一个域名下有效)
if (Objects.nonNull(session)) {
// 5、重定向到主页
final String urlToRedirectTo = casClientFrontUrl + "/home/index?jsessionid=" + session.getId();
if (log.isDebugEnabled()) {
log.debug("redirecting to \"" + urlToRedirectTo + "\"");
}
response.sendRedirect(urlToRedirectTo);
}
}
前端需要新增全局拦截器,未登录状态下一律拦截到
cas/login
接口,登录成功后会将会重定向至配置的前端页面,并追加面jsessionid参数,需要将jsessionid写入cookie,后续所有请求保持和后端jsessionid一致(注意跨域)
认证中心登录成功,重定向到前端项目指定地址,去除 cas/index
模拟方法。
5、修改 cas/logout 认证登出逻辑,重定向到认证中心登录界面 :
@GetMapping("/logout")
@ApiOperation("cas 单点登出")
public void logout(HttpServletRequest request, HttpServletResponse response, @RequestParam(required = false) String url) throws IOException {
// 1、获取当前本地会话
HttpSession session = request.getSession(false);
if (Objects.nonNull(session)) {
// 1.1、本地会话过期
session.invalidate();
}
// 2、构造CAS Server登出URL,其中service参数为本地登出回调地址
// The URL to the CAS Server Single login.
String casSingleLoginUrl = CommonUtils.constructRedirectUrl(casProperties.getServerLoginUrl(),
Protocol.CAS2.getServiceParameterName(),
casProperties.getClientHostUrl() + "/cas/login",
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
// The URL to the Server Single logout.
String casSingleLogoutUrl = CommonUtils.constructRedirectUrl(casProperties.getServerUrlPrefix() + "/logout",
Protocol.CAS2.getServiceParameterName(),
casSingleLoginUrl,
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
// 3、重定向到CAS Server登出
response.sendRedirect(casSingleLogoutUrl);
}
本地登出成功,重定向到认证中心的登录界面,去除 cas/logoutPage
模拟方法。
6、项目配置
Yaml 配置
############################################################################################
###Cas 认证(CasClientConfigurationProperties)配置:
############################################################################################
# CAS-protected client Ignore Pattern Path E.g. /api/cas/**. Optional.
cas-ignore-pattern-path: '**/cas/address,**/cas/logout,**/swagger-ui/*,**/webjars/*,**/swagger-resources/*,**/v2/api-docs'
# Cas Configuration
cas:
# CAS server URL E.g. https://example.com/cas or https://cas.example. Required.
server-url-prefix: https://example.com
# CAS server login URL E.g. https://example.com/cas/login or https://cas.example/login. Required.
server-login-url: ${cas.server-url-prefix}/login
# CAS-protected client application host URL E.g. https://myclient.example.com Required.
client-host-url: https://myclient.example.com
# Hostname Verifier to use for HTTPS connections. Defaults to DefaultHostnameVerifier.
#hostname-verifier: DefaultHostnameVerifier
# SslConfig for HTTPS connections.
#ssl-config-file: classpath:conf/cas/ssl.properties
# ValidationType the CAS protocol validation type. Defaults to CAS3 if not explicitly set.
validation-type: CAS3
# Validation filter redirectAfterValidation. Defaults to true.
redirect-after-validation: false
# Whether to receive the single logout request from cas server.
single-logout:
enabled: true
Properties 配置
# CAS-protected client Ignore Pattern Path E.g. /api/cas/**. Optional.
cas-ignore-pattern-path=**/cas/address,**/cas/logout,**/swagger-ui/*,**/webjars/*,**/swagger-resources/*,**/v2/api-docs
# Cas Configuration
cas.server-url-prefix=https://example.com
cas.server-login-url=${cas.server-url-prefix}/login
cas.client-host-url=https://myclient.example.com
cas.validation-type=CAS3
cas.single-logout.enabled=true
cas.redirect-after-validation=false
7、前端Vue项目调整
7.1、VueRouter 添加 路由拦截逻辑,处理重定向携带 JSESSIONID:
import Router from 'vue-router';
Vue.use(Router);
const vueRouter = new Router({
mode: 'history',
base: process.env.VUE_APP_PATH,
routes: []
});
const beforeEachRoute = async (to, _, next) => {
const { path, name, query } = to;
// 1、对url带jsessionid路由的处理
if (query.jsessionid) {
// 清除cookie
const cookiesList = cookies.keys();
cookiesList.forEach((item) => {
cookies.remove(item);
});
cookies.set('JSESSIONID', query.jsessionid);
let _query = { ...query };
delete _query.jsessionid;
next({
path,
query: _query
})
return;
} else {
// 2、url不带jsessionid,说明是其他路由页面访问,需要判断登录状态
const oldJessionid = cookies.get('JSESSIONID');
console.log('oldJessionid', oldJessionid);
// 2.1、如果cookie里有JSESSIONID,说明已经登录,直接跳转
if (oldJessionid) {
next();
return;
}
// 2.2、如果cookie里没有JSESSIONID,说明没有登录,跳转到登录页面
getLoginAddress().then((res) => {
// 清除cookie和localStorage
const cookiesList = cookies.keys();
cookiesList.forEach((item) => {
cookies.remove(item);
});
localStorage.removeItem("JSESSIONID");
// 浏览器在当前窗口,跳转到认证中心登录页面
console.log('res', res);
if (res && res.data && res.data.loginUrl) {
window.open(res.data.loginUrl, '_self');
}
})
.catch(() => {
location.reload();
}).finally(() => {
});
}
};
vueRouter.beforeEach(beforeEachRoute);
7.2、Axios 添加 HTTP 请求参数
axios.defaults.timeout = 30000;
// 返回其他状态吗
axios.defaults.validateStatus = function (status) {
return status >= 200 && status <= 500; // 默认的
};
// 设置X-Requested-With请求头默认值
axios.defaults.headers["X-Requested-With"] = 'XMLHttpRequest';
// 跨域请求,允许保存cookie
axios.defaults.withCredentials = true;
7.3、Axios 添加 HTTP 响应拦截逻辑,处理 401 异常
import cookies from 'vue-cookies';
import axios from 'axios';
// HTTP 响应拦截
axios.interceptors.response.use((config) => {
const res = config.data;
// 授权失败
if (config.status === 401 || res.code === 401) {
// 清理 Cookie:该短代码很重要,防止跳转认证平台后,再次跳转到本系统时,Cookie 仍然存在,存在多个JSESSIONID 导致Session无法保持
const cookiesList = cookies.keys();
cookiesList.forEach((item) => {
cookies.remove(item);
});
// 重定向到登录页面
if(res.data && res.data.loginUrl){
window.open(res.data.loginUrl, '_self');
return;
}
}
// 其他逻辑
}
8、验证Cas认证接入情况
认证验证流程:
8.1、首先访问 前端主页
, 比如:http://192.168.0.20:8080
前端Vue路由守卫,执行登录状态检查逻辑
- 路由守卫 vueRouter.beforeEach() ,检查登录状态
- 已登录(JSESSIONID 有效),则进入主页
- 未登录(JSESSIONID 失效),调用 cas/address 接口获取 loginUrl 地址
8.2、在统一身份认证平台完成登录验证
- 账号密码登录或其他方式登录,登录成功,重定向
/cas/login
接口 /cas/login
接口获取认证信息失败:跳转到 “统一身份认证平台” 的登录页面;获取认证信息成功:关联本地用户、生成Token、写 Cookie 信息- 完成本地用户关联后,携带
jsessionid
参数,重定向到前端主页
8.3、前端主页,执行路由鉴权逻辑
- 路由守卫 vueRouter.beforeEach() ,jsessionid 参数处理,保存 Cookie
- 路由重定向到主页
- 主页正常调用后端接口
8.4、后端服务,执行接口鉴权拦截
- 接口后端拦截,已登录(JSESSIONID 有效),执行接口逻辑
- 未登录(JSESSIONID 失效),返回 401 状态码 和 登录地址
- 接口响应拦截器,拦截401,重新跳转到 “统一身份认证平台” 的登录页面
- 再次输入账号密码或使用其他方式登录登录成功,重定向回三方系统
/cas/login
接口 - 已经关联本地用户,重定向到前端主页
9、本地账号关联
假设有用户表
CREATE TABLE `ag_user` (
`id` bigint(19) NOT NULL COMMENT '主键',
`account` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '账户名称',
`is_admin` tinyint(4) DEFAULT NULL COMMENT '是否是超管',
`name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`cas_user_id` bigint(20) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='用户';
定义 Service 接口
import com.baomidou.mybatisplus.extension.service.IService;
import org.jasig.cas.client.validation.Assertion;
public interface IUserService extends IService<User> {
/**
* 关联Cas用户
* @param assertion
* @return
*/
boolean saveOrUpdate(Assertion assertion);
}
实现 Service 接口
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.apache.commons.collections4.MapUtils;
import org.jasig.cas.client.validation.Assertion;
import java.util.Map;
import java.util.Objects;
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public boolean saveOrUpdate(Assertion assertion) {
// 1、如果有认证信息,获取用户信息
Long casUserId = CasUtil.getUserAccountId(assertion);
Map<String, Object> userInfo = CasUtil.getUserAttributes(assertion);
// 2、根据casUserId查询用户信息
User entity = getBaseMapper().selectOne(new LambdaQueryWrapper<User>().eq(User::getCasUserId, casUserId));
if(Objects.nonNull(entity)){
entity = new User()
.setId(IdWorker.getId())
.setName(MapUtils.getString(userInfo, "nickname"))
.setAccount(CasUtil.getUserAccount())
.setCasUserId(casUserId);
return this.update(entity, new LambdaUpdateWrapper<User>().eq(User::getCasUserId, entity.getCasUserId()));
} else {
entity = new User()
.setId(IdWorker.getId())
.setName(MapUtils.getString(userInfo, "nickname"))
.setAccount(CasUtil.getUserAccount())
.setCasUserId(casUserId)
.setIsAdmin(Boolean.FALSE);
return this.save(entity);
}
}
}
定义 Mapper 接口
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
定义 User 实体
@Data
@Accessors(chain = true)
@FieldNameConstants
@TableName("ag_user")
public class User {
@TableId
private Long id;
private String account;
private String name;
private Boolean isAdmin;
private Long casUserId;
}
在 /cas/login 接口添加关联用户逻辑
@GetMapping("login")
@ApiOperation("Cas 单点登录")
public void login(HttpServletRequest request, HttpServletResponse response) throws IOException {
String origin = request.getHeader("Origin");
// 简单请求跨域,如果是跨域请求在响应头里面添加对应的Origin
if (StringUtils.hasText(origin)) {
response.addHeader("Access-Control-Allow-Origin", origin);
} else {
response.addHeader("Access-Control-Allow-Origin", "*");
}
// 非简单请求跨域
response.addHeader("Access-Control-Allow-Headers", "*");
// 允许跨域请求的方法
response.addHeader("Access-Control-Allow-Methods", "*");
// 携带cookie的跨域
response.addHeader("Access-Control-Allow-Credentials", "true");
// 1、从上下文中获取CAS认证信息
HttpSession session = request.getSession(false);
Assertion assertion = (Assertion) (session == null ? request
.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION) : session
.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION));
// 2、如果没有认证信息,重定向到CAS Server进行认证
if (Objects.isNull(assertion)) {
// 2.1、重定向到登录页面
String casSingleLoginUrl = CommonUtils.constructRedirectUrl(casProperties.getServerLoginUrl(),
Protocol.CAS2.getServiceParameterName(),
casProperties.getClientHostUrl() + "/cas/login",
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
response.sendRedirect(casSingleLoginUrl);
return;
}
// 3、关联Cas用户
userService.saveOrUpdate(assertion);
// 4、手动设置Cookie(前端地址与后端接口在一个域名下有效)
if (Objects.nonNull(session)) {
// 5、重定向到主页
final String urlToRedirectTo = casClientFrontUrl + "/home/index?jsessionid=" + session.getId();
if (log.isDebugEnabled()) {
log.debug("redirecting to \"" + urlToRedirectTo + "\"");
}
response.sendRedirect(urlToRedirectTo);
}
}
四、涉及的工具
WebUtils.java
@Slf4j
public class WebUtils extends org.springframework.web.util.WebUtils {
private static final String XML_HTTP_REQUEST = "XMLHttpRequest";
private static final String X_REQUESTED_WITH = "X-Requested-With";
private static final String CONTENT_TYPE_JSON = "application/json";
public static boolean isAjaxResponse(HttpServletRequest request ) {
return isAjaxRequest(request) || isContentTypeJson(request) || isPostRequest(request);
}
public static boolean isObjectRequest(HttpServletRequest request ) {
return isPostRequest(request) && isContentTypeJson(request);
}
public static boolean isAjaxRequest(HttpServletRequest request ) {
return XML_HTTP_REQUEST.equals(request.getHeader(X_REQUESTED_WITH));
}
public static boolean isContentTypeJson(HttpServletRequest request ) {
return request.getHeader(HttpHeaders.CONTENT_TYPE).contains(CONTENT_TYPE_JSON);
}
public static boolean isPostRequest(HttpServletRequest request ) {
return HttpMethod.POST.compareTo(HttpMethod.resolve(request.getMethod()) ) == 0;
}
public static boolean isPostRequest(HttpRequest request ) {
return HttpMethod.POST.compareTo(request.getMethod()) == 0;
}
}
CasUtil.java
import lombok.extern.slf4j.Slf4j;
import org.jasig.cas.client.authentication.AttributePrincipal;
import org.jasig.cas.client.util.AbstractCasFilter;
import org.jasig.cas.client.util.AssertionHolder;
import org.jasig.cas.client.validation.Assertion;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.*;
/**
* Cas 工具类
*/
@Slf4j
public class CasUtil {
/**
* 获取 Assertion
*
* @param request request
* @return Assertion
*/
public static Assertion getAssertion(HttpServletRequest request) {
HttpSession session = request.getSession();
return (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
}
/**
* 获取 HttpServletRequest
*
* @return HttpServletRequest
*/
public static HttpServletRequest getRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
assert attributes != null;
return attributes.getRequest();
}
/**
* 获取 account
*
* @return account
*/
public static String getUserAccount() {
log.info("当前用户:{}", getRequest().getRemoteUser());
return getRequest().getRemoteUser();
}
/**
* 判断当前用户是否是超级管理员
*
* @return 是或不是
*/
public static Boolean isAdmin() {
// 获取配置文件中的超级管理员集合
List<String> adminAccounts = getAdminAccountListDateBase();
// 若是超级管理员
return adminAccounts.contains(getUserAccount());
}
public static List<String> getAdminAccountListDateBase() {
return CasUserUtil.getAllAdminAccount();
}
/**
* 获取 accountId
*
* @return accountId
*/
public static Long getUserAccountId() {
return Long.valueOf(String.valueOf(Optional.ofNullable(getUserAttributes().get("accountId")).orElse("0")));
}
/**
* 获取 accountId
*
* @return accountId
*/
public static Long getUserAccountId(Assertion assertion) {
return Long.valueOf(String.valueOf(Optional.ofNullable(getUserAttributes(assertion).get("accountId")).orElse("0")));
}
/**
* 获取用户属性
*
* @return 用户属性
*/
public static Map<String, Object> getUserAttributes() {
Assertion assertion = AssertionHolder.getAssertion();
return Objects.isNull(assertion) ? Collections.emptyMap() : getUserAttributes(assertion);
}
/**
* 获取用户属性
*
* @return 用户属性
*/
public static Map<String, Object> getUserAttributes(Assertion assertion) {
AttributePrincipal principal = assertion.getPrincipal();
return Objects.isNull(principal) ? new HashMap<>() : principal.getAttributes();
}
/**
* 获取 accountId
*
* @return accountId
*/
public static Long getUserAccountIdIgnoreEx() {
return Long.valueOf(String.valueOf(Optional.ofNullable(getUserAttributesIgnoreEx().get("accountId")).orElse("0")));
}
/**
* 获取用户属性
*
* @return 用户属性
*/
public static Map<String, Object> getUserAttributesIgnoreEx() {
try {
Assertion assertion = AssertionHolder.getAssertion();
AttributePrincipal principal = assertion.getPrincipal();
log.info("Principal Name : {} , Attributes: {}", principal.getName(), principal.getAttributes());
return Objects.isNull(principal) ? Collections.EMPTY_MAP : principal.getAttributes();
} catch (Exception e){
log.error(e.getMessage());
return Collections.EMPTY_MAP;
}
}
}
SpringBeanUtils.java
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
@Component
public class SpringBeanUtils implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext){
SpringBeanUtils.applicationContext = applicationContext;
}
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
}
五、常见问题
1、Cas登录成功后重定向 /cas/login
,登录状态失效
原因:
在 CustomAuthRedirectStrategy
的 redirect
方法中,我们通过下面的代码生成了单点登录、单点登出地址
/**
* The URL to the CAS Server Single login.
*/
String casSingleLoginUrl = CommonUtils.constructRedirectUrl(casProperties.getServerLoginUrl(),
Protocol.CAS2.getServiceParameterName(),
casProperties.getClientHostUrl() + "/cas/login",
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
/**
* The URL to the CAS Server Single logout.
*/
String casSingleLogoutUrl = CommonUtils.constructRedirectUrl(casProperties.getServerUrlPrefix() + "/logout",
Protocol.CAS2.getServiceParameterName(),
casProperties.getClientHostUrl() + "/cas/logoutPage",
Boolean.FALSE, Optional.ofNullable(casProperties.getGateway()).orElse(Boolean.FALSE));
- 单点登录地址:http://192.168.3.27:31495/login?service=http%3A%2F%2F192.168.0.20%3A8081%2Fcas%2Flogin
- 单点登出地址:http://192.168.3.27:31495/logout?service=http%3A%2F%2F192.168.0.20%3A8081%2Fcas%2FlogoutPage
其中,单点登出回调地址 /cas/logoutPage
是不被拦截的,这是一个模拟单点登出页面的接口,接口返回内容包含了一个全局登出地址,用于演示全局登出逻辑
在 AbstractTicketValidationFilter
的 doFilter
方法中,在完成ticket票据校验逻辑后,将验证结果放入 Rquest
和 Session
后,会重定向到 constructServiceUrl
方法构造的地址,解决这个问题的方法有2个,
http://192.168.3.27:31495/login?service=http%3A%2F%2F192.168.0.20%3A8081%2Fcas%2Flogin
doFilter 代码片段:
final Assertion assertion = this.ticketValidator.validate(ticket,
constructServiceUrl(request, response));
logger.debug("Successfully authenticated user: {}", assertion.getPrincipal().getName());
request.setAttribute(CONST_CAS_ASSERTION, assertion);
if (this.useSession) {
request.getSession().setAttribute(CONST_CAS_ASSERTION, assertion);
}
onSuccessfulValidation(request, response, assertion);
if (this.redirectAfterValidation) {
logger.debug("Redirecting after successful ticket validation.");
response.sendRedirect(constructServiceUrl(request, response));
return;
}
constructServiceUrl 代码片段:
protected final String constructServiceUrl(final HttpServletRequest request, final HttpServletResponse response) {
return CommonUtils.constructServiceUrl(request, response, this.service, this.serverName,
this.protocol.getServiceParameterName(),
this.protocol.getArtifactParameterName(), this.encodeServiceUrl);
}
2、登录状态下获取用户信息
直接获取登录用户名
request.getRemoteUser();
获取详细用户信息
AttributePrincipal principal = (AttributePrincipal)request.getUserPrincipal();
Map attributes = principal.getAttributes();
Object moblie=attributes .get("moblie");
参考资料:
https://blog.csdn.net/qq2456939181/article/details/127935967
最后编辑:Jeebiz 更新时间:2024-05-07 20:29