发布时间:2024-11-15 10:01
前后端分离已在互联网项目开发业界进行了广泛应用,通过前端应用与后端服务的分布式部署可以有效进行解耦,将数据与展现彻底分离,既保证了数据安全,也给了前端开发充分的自由。
前后端分离最常见的实现方式之一是前端 HTML 页面通过 AJAX 调用后端的 RESTFUL API 接口并使用 JSON 数据进行交互(这种方式也为单点登录方案的实现挖了个大坑)。
单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
![](https://img-blog.csdnimg.cn/img_convert/70bfb2d753ef31d5ef744132f95725a3.png#clientId=ud900accb-992e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u4b8b93eb&margin=[object Object]&originHeight=931&originWidth=737&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u6b587b1a-6244-45ec-bb42-34218ff12b0&title=)
CAS Server(CAS服务端)负责完成对用户的认证工作,完成与浏览器端的用户认证和CAS客户端的票据验证。
CAS Client(CAS客户端)负责处理对受保护资源的访问请求,需要对请求方进行身份认证时,重定向到 CAS Server 进行认证。 CAS Client 与受保护的客户端应用部署在一起,以 Filter 方式保护受保护的资源。
TGT 是 CAS 为用户签发的登录票据,拥有了 TGT,用户就可以证明自己在CAS成功登录过。 TGT 封装了 Cookie 值以及此 Cookie 值对应的用户信息。用户在 CAS 认证成功后,CAS 生成 cookie(叫TGC),写入浏览器,同时生成一个 TGT 对象,放入自己的缓存,TGT 对象的 ID 就是 cookie 的值。 当 HTTP 再次请求到来时,如果传过来的有 CAS 生成的 cookie,则 CAS 以此 cookie 值为 key 查询缓存中有无 TGT,如果有,说明用户之前登录过,如果没有,则用户需要重新登录。
存放用户身份认证凭证的 cookie,在浏览器和 CAS Server 间通讯时使用,并且只能基于安全通道传输(Https),是 CAS Server 用来明确用户身份的凭证。
服务票据,服务的惟一标识码 , 由 CAS Server 发出( Http 传送),用户访问 Service 时,Service 发现用户没有 ST,则要求用户去 CAS 获取 ST。
前文已经介绍了 CAS 认证的过程,可以看出 CAS 的认证基于会话(即浏览器与服务器之间的 Session),因此终端、CAS 客户端与 CAS 服务端会组成一个三方的认证系统。登录之后的浏览器会在 CAS Server 的域名下存放 cookie,用于浏览器和 CAS Server 之间验证是否登录;而在访问 CAS Client 资源时则会在 Client 的域名下存放一个 cookie,用于下次访问资源时调取 ST 与 CAS Server 进行验证。
现在问题出现了。当前端与后端分离时,原本的 CAS Client 就不再是一方了,而是变成了两方,于是三方认证也成了四方认证。 如果是单纯的变成了两方也并没有离开 CAS 的认证框架,无非是多一个 CAS Client 罢了,然而前端常用的 Ajax 请求恰好无法处理 CAS 中最常见的重定向操作。这样一来,包括首次登录、登录成功后返回 ST、认证登录等一系列的逻辑似乎都没有办法继续进行了。
<!-- CAS客户端SpringBoot自动配置 -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-support-springboot</artifactId>
<version>3.6.4</version>
</dependency>
<!-- hutool工具包 -->
<!-- 实现自定义重定向策略时会用到,如不需要可以不引入该依赖 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.0.M4</version>
</dependency>
cas配置属性
cas.single-logout.enabled
cas.authentication-url-patterns
cas.validation-url-patterns
cas.request-wrapper-url-patterns
cas.assertion-thread-local-url-patterns
cas.gateway
cas.use-session
cas.attribute-authorities
cas.redirect-after-validation
cas.allowed-proxy-chains
cas.proxy-callback-url
cas.proxy-receptor-url
cas.accept-any-proxy
server.context-parameters.renew
这里以application.yml
为例
########################cas配置属性###########################
cas:
# CAS服务器URL
server-url-prefix: http://192.168.129.229:8456/cas
# CAS服务器登录URL
server-login-url: http://192.168.129.229:8456/cas/login
# 后端程序URL
client-host-url: http://192.168.128.121:8084
# 认证请求拦截路径
authentication-url-patterns:
- /api/pri/acc/auth
# 校验拦截路径
validation-url-patterns:
- /api/pri/acc/auth
# 对路径进行包装,之后就可在request中获取到用户信息
request-wrapper-url-patterns:
- /api/pri/acc/*
# 当前线程中哪些路径可以获取到用户信息
assertion-thread-local-url-patterns:
- /api/pri/acc/*
#身份验证和验证过滤器协议类型,如果没有指定,则默认为 cas3协议
validation-type: cas3
#是否启用单点登出
single-logout:
enabled: true
########################自定义配置属性###########################
# CAS控制开关 true:启用登录验证 false:不启用
cas-enable: true
# 后端程序认证路径
client-auth-pattern: /api/pri/acc/auth
# 前端应用主页URL
web-main-url: http://192.168.128.121:8084/#/mainpage
@Component
@Data
public class CASEnable {
// 控制开关 true:启用登录验证 false:不启用
@Value("${cas-enable}")
private Boolean enable;
}
@Component
@Data
public class CASUrls {
// CAS服务器URL
@Value("${cas.server-url-prefix}")
private String serverUrlPrefix;
// CAS服务器登录URL
@Value("${cas.server-login-url}")
private String serverLoginurl;
// 后端程序URL
@Value("${cas.client-host-url}")
private String clientHostUrl;
// 后端程序认证路径
@Value("${client-auth-pattern}")
private String clientAuthPattern;
// 前端应用主页URL
@Value("${web-main-url}")
private String webMainUrl;
}
@RestController
@RequestMapping("/api/pri/acc/")
@Api(tags = "登录")
public class LoginController {
@Autowired
CASEnable casEnable;
@Autowired
CASUrls casUrls;
@GetMapping("auth")
public void auth(HttpServletRequest request, HttpServletResponse response, @RequestParam(required = false) String fromfront) {
if (casEnable.getEnable()){
String sessionId = request.getSession().getId();
try {
if ("false".equals(fromfront)) {
response.setContentType("text/html;charset=UTF-8");
response.setHeader("Access-Control-Allow-Origin", "*");
response.sendRedirect(casUrls.getWebMainUrl()+"?sessionId="+sessionId);
}else {
response.setContentType("application/json; charset=UTF-8");
PrintWriter out = response.getWriter();
out.write(AjaxJson.getNotLogin().setData("\\""+casUrls.getWebMainUrl()+"?sessionId="+sessionId+"\\"").toString());
}
} catch (IOException e) {
e.printStackTrace();
}
}else {
try {
response.setContentType("application/json; charset=UTF-8");
PrintWriter out = response.getWriter();
out.write(AjaxJson.getNotLogin().setData("\\""+casUrls.getWebMainUrl() + "?sessionId=xxxxx\\"").toString());
} catch (IOException e) {
e.printStackTrace();
}
}
}
@GetMapping("dologin")
@ApiOperation(value="登录",notes = "无参,直接取CAS单点登录的用户信息,如CAS关闭,则使用默认值")
public AjaxJson doLogin(HttpSession session) {
if (casEnable.getEnable()){
AssertionImpl assertion= (AssertionImpl) session.getAttribute("_const_cas_assertion_");
if(assertion!=null&&assertion.getPrincipal().getName()!=null) {
//获取用户名执行登录操作
StpUtil.login(assertion.getPrincipal().getName());
return AjaxJson.getSuccess("登录成功",StpUtil.getTokenInfo());
}else {
return AjaxJson.getNotLogin().setData(casUrls.getServerLoginurl()+"?service="+casUrls.getClientHostUrl()+casUrls.getClientAuthPattern()+"?fromfront=false");
}
}else {
//执行登录操作
StpUtil.login("20000000");
return AjaxJson.getSuccess("登录成功",StpUtil.getTokenInfo());
}
}
}
/**
* ajax请求返回Json格式数据的封装
*/
public class AjaxJson implements Serializable{
private static final long serialVersionUID = 1L; // 序列化版本号
public static final int CODE_SUCCESS = 200; // 成功状态码
public static final int CODE_ASYNC_ING = 201; // 异步任务执行中状态码
public static final int CODE_ERROR = 500; // 错误状态码
public static final int CODE_WARNING = 501; // 警告状态码
public static final int CODE_NOT_JUR = 403; // 无权限状态码
public static final int CODE_NOT_LOGIN = 401; // 未登录状态码
public static final int CODE_INVALID_REQUEST = 400; // 无效请求状态码
public int code; // 状态码
public String msg; // 描述信息
public Object data; // 携带对象
public Long dataCount; // 数据总数,用于分页
/**
* 返回code
*/
public int getCode() {
return this.code;
}
/**
* 给msg赋值,连缀风格
*/
public AjaxJson setMsg(String msg) {
this.msg = msg;
return this;
}
public String getMsg() {
return this.msg;
}
/**
* 给data赋值,连缀风格
*/
public AjaxJson setData(Object data) {
this.data = data;
return this;
}
/**
* 将data还原为指定类型并返回
*/
@SuppressWarnings("unchecked")
public <T> T getData(Class<T> cs) {
return (T) data;
}
// ============================ 构建 ==================================
public AjaxJson(int code, String msg, Object data, Long dataCount) {
this.code = code;
this.msg = msg;
this.data = data;
this.dataCount = dataCount;
}
// 返回成功
public static AjaxJson getSuccess() {
return new AjaxJson(CODE_SUCCESS, "ok", null, null);
}
public static AjaxJson getSuccess(String msg) {
return new AjaxJson(CODE_SUCCESS, msg, null, null);
}
public static AjaxJson getSuccess(String msg, Object data) {
return new AjaxJson(CODE_SUCCESS, msg, data, null);
}
public static AjaxJson getSuccessData(Object data) {
return new AjaxJson(CODE_SUCCESS, "ok", data, null);
}
public static AjaxJson getSuccessArray(Object... data) {
return new AjaxJson(CODE_SUCCESS, "ok", data, null);
}
// 返回失败
public static AjaxJson getError() {
return new AjaxJson(CODE_ERROR, "error", null, null);
}
public static AjaxJson getError(String msg) {
return new AjaxJson(CODE_ERROR, msg, null, null);
}
// 返回警告
public static AjaxJson getWarning() {
return new AjaxJson(CODE_ERROR, "warning", null, null);
}
public static AjaxJson getWarning(String msg) {
return new AjaxJson(CODE_WARNING, msg, null, null);
}
// 返回未登录
public static AjaxJson getNotLogin() {
return new AjaxJson(CODE_NOT_LOGIN, "未登录,请登录后再次访问", null, null);
}
// 返回没有权限的
public static AjaxJson getNotJur(String msg) {
return new AjaxJson(CODE_NOT_JUR, msg, null, null);
}
// 返回一个自定义状态码的
public static AjaxJson get(int code, String msg){
return new AjaxJson(code, msg, null, null);
}
public static AjaxJson get(int code, String msg,Object data){
return new AjaxJson(code, msg, data, null);
}
public static AjaxJson get(int code, String msg,Object data,Long dataCount){
return new AjaxJson(code, msg, data, dataCount);
}
// 返回分页和数据的
public static AjaxJson getPageData(Long dataCount, Object data){
return new AjaxJson(CODE_SUCCESS, "ok", data, dataCount);
}
// 返回,根据受影响行数的(大于0=ok,小于0=error)
public static AjaxJson getByLine(int line){
if(line > 0){
return getSuccess("ok", line);
}
return getError("error").setData(line);
}
// 返回,根据布尔值来确定最终结果的 (true=ok,false=error)
public static AjaxJson getByBoolean(boolean b){
return b ? getSuccess("ok") : getError("error");
}
/* (non-Javadoc)
* @see java.lang.Object#toString()
*/
@SuppressWarnings("rawtypes")
@Override
public String toString() {
String data_string = null;
if(data == null){
} else if(data instanceof List){
data_string = "List(length=" + ((List)data).size() + ")";
} else {
data_string = data.toString();
}
return "{"
+ "\\"code\\": " + this.getCode()
+ ", \\"msg\\": \\"" + this.getMsg() + "\\""
+ ", \\"data\\": " + data_string
+ ", \\"dataCount\\": " + dataCount
+ "}";
}
}
package com.demo.backend.config;
import cn.hutool.extra.spring.SpringUtil;
import com.sugon.backend.utils.AjaxJson;
import org.jasig.cas.client.authentication.AuthenticationRedirectStrategy;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
public class CustomAuthRedirectStrategy implements AuthenticationRedirectStrategy {
// 重写AuthenticationFilter重定向策略
@Override
public void redirect(HttpServletRequest request, HttpServletResponse response, String s) throws IOException {
// SpringUtil.getProperty方法来自于hutool,如不使用hutool工具,可自己实现获取配置项属性
String serverLoginUrl = SpringUtil.getProperty("cas.server-login-url");
String clientHostUrl = SpringUtil.getProperty("cas.client-host-url");
String clientAuthPattern = SpringUtil.getProperty("client-auth-pattern");
response.setCharacterEncoding("utf-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter out = response.getWriter();
out.write(AjaxJson.getNotLogin().setData("\\""+serverLoginUrl+"?service="+clientHostUrl+clientAuthPattern+"?fromfront=false\\"").toString());
}
}
可根据需要对四个方法进行重写
package com.demo.backend.config;
import org.jasig.cas.client.boot.configuration.CasClientConfigurer;
import org.jasig.cas.client.boot.configuration.EnableCasClient;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Configuration;
@ConditionalOnProperty(value = "cas-enable", matchIfMissing = true)
@Configuration
@EnableCasClient
public class CASConfig implements CasClientConfigurer {
// CAS Client向CAS Server进行ticket验证
@Override
public void configureValidationFilter(FilterRegistrationBean validationFilter) {
// 根据需要自行调整order优先级(如不设置,默认是1)
validationFilter.setOrder(Ordered.HIGHEST_PRECEDENCE+1);
CasClientConfigurer.super.configureValidationFilter(validationFilter);
}
// 登录认证,未登录用户导向CAS Server进行认证
@Override
public void configureAuthenticationFilter(FilterRegistrationBean authenticationFilter) {
// 根据需要自行调整order优先级(如不设置,默认是2)
authenticationFilter.setOrder(Ordered.HIGHEST_PRECEDENCE+2);
// 指定自定义AuthenticationFilter重定向策略,注意value是上一步骤新建文件的全类名
Map<String, String> initParameters = authenticationFilter.getInitParameters();
initParameters.put("authenticationRedirectStrategyClass", "com.demo.backend.config.CustomAuthRedirectStrategy");
authenticationFilter.setInitParameters(initParameters);
CasClientConfigurer.super.configureAuthenticationFilter(authenticationFilter);
}
// 封装request, 支持getUserPrincipal等方法
@Override
public void configureHttpServletRequestWrapperFilter(FilterRegistrationBean httpServletRequestWrapperFilter) {
// 根据需要自行调整order优先级(如不设置,默认是3)
httpServletRequestWrapperFilter.setOrder(Ordered.HIGHEST_PRECEDENCE+3);
CasClientConfigurer.super.configureHttpServletRequestWrapperFilter(httpServletRequestWrapperFilter);
}
// 存放Assertion到ThreadLocal中,使其他类的方法不用通过Request对象就能获得用户登录信息
@Override
public void configureAssertionThreadLocalFilter(FilterRegistrationBean assertionThreadLocalFilter) {
// 根据需要自行调整order优先级(如不设置,默认是4)
assertionThreadLocalFilter.setOrder(Ordered.HIGHEST_PRECEDENCE+4);
CasClientConfigurer.super.configureAssertionThreadLocalFilter(assertionThreadLocalFilter);
}
}
①判断是否存在sessionId或cookie
②如果存在,将sessionId保存到cookie,sessionId优先级更高,如果sessionId与当前已存在的cookie不一致,用sessionId替换掉当前cookie,并开始走登录接口(dologin)。
③如果都不存在,则走认证接口(auth),前后端约定通过代码为401的响应返回重定向地址,通过前端进行跳转。
npm i js-cookie
login(){
var cookie = Cookies.get('JSESSIONID')//获取cookie
if (this.$route.query.sessionId){//判断路径中是否存在sessionId
cookie = Cookies.get('JSESSIONID')
this.dologin()//封装调用后端dologin接口,换成自己的就行
}
else if(cookie && cookie!=null && cookie!=undefined){//判断cookie是否存在
this.dologin()
}
else {
this.auth()//认证接口,调用后端auth接口
}
},
watch: {
'$route'(to,from) {
this.login()
}
},
要注意约定的code401是放在第二层的
service.interceptors.response.use(response => {
if (response.status === 200) {
if (response.data.code && response.data.code === 401){
let url = response.data.data;
window.location.href = url;
}
return response.data;
}
else {
console.log(response);
Promise.reject();
}
}, error => {
return Promise.resolve(error.response)
});
参考文档
GitHub - apereo/java-cas-client: Apereo Java CAS Client
Springboot前后端分离实现CAS单点登录_JasonWangQB的博客-CSDN博客_前后端分离cas单点登录
前后端分离模式下 CAS 单点登录实现方案 - 灰信网(软件开发博客聚合)
Cas单点登录集成前后端分离项目_折纸纸飞机的博客-CSDN博客_cas 前后端分离
CAS 入门实战(3)–客户端接入_咏吟的技术博客_51CTO博客