Spring Boot 整合 CAS (前后端分离整合部署和分开部署均适用)

知识点

前后端分离

前后端分离已在互联网项目开发业界进行了广泛应用,通过前端应用与后端服务的分布式部署可以有效进行解耦,将数据与展现彻底分离,既保证了数据安全,也给了前端开发充分的自由。
前后端分离最常见的实现方式之一是前端 HTML 页面通过 AJAX 调用后端的 RESTFUL API 接口并使用 JSON 数据进行交互(这种方式也为单点登录方案的实现挖了个大坑)。

单点登录

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

CAS

![](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=)

用户登录

  1. 用户通过浏览器发送请求访问 CAS Client 资源
  2. CAS Client 发现用户请求中未包含 ST 票据,将浏览器重定向到 CAS Server,此时 URL 中会携带名为 service 的参数,参数值是用户要访问的客户端资源地址
  3. CAS Server 对访问的用户是否携带 TGC 进行验证,若未携带则跳转到 CAS 统一的登录页面
  4. 用户登录后,CAS Server 将浏览器重定向到之前 service 参数值指向的客户端地址(URL 的最后会增加 st 参数,CAS Client 可将 ST 保存起来),同时生成 TGC 写入浏览器中
  5. 由于此次重定向携带了 ST,CAS Client 会向 CAS Server 发送验证请求
  6. CAS Server 验证通过,用户可以正常访问资源
  7. 此时浏览器已与 CAS Client 建立会话,若 CAS Client 保存了 ST,后续请求通过会话即可调取 ST 并与 CAS Server 进行验证

已登录用户访问其他资源

  1. 用户访问未建立会话的 CAS Client 资源
  2. CAS Client 需要 ST 进行验证,将浏览器重定向到 CAS Server
  3. 用户访问 CAS Server,CAS Server 发现用户有 TGT,签发一个 ST,返回给用户浏览器并重定向到 CAS Client
  4. CAS Client 发现有 ST 去 CAS Server(CAS Client 可将 ST 保存起来) 验证,验证通过后,允许用户访问资源
  5. 此时浏览器已与 CAS Client 建立会话,若 CAS Client 保存了 ST,后续请求通过会话即可调取 ST 并与 CAS Server 进行验证

CAS SERVER

CAS Server(CAS服务端)负责完成对用户的认证工作,完成与浏览器端的用户认证和CAS客户端的票据验证。

CAS CLIENT

CAS Client(CAS客户端)负责处理对受保护资源的访问请求,需要对请求方进行身份认证时,重定向到 CAS Server 进行认证。 CAS Client 与受保护的客户端应用部署在一起,以 Filter 方式保护受保护的资源。

TICKET GRANGTING TICKET(TGT)

TGT 是 CAS 为用户签发的登录票据,拥有了 TGT,用户就可以证明自己在CAS成功登录过。 TGT 封装了 Cookie 值以及此 Cookie 值对应的用户信息。用户在 CAS 认证成功后,CAS 生成 cookie(叫TGC),写入浏览器,同时生成一个 TGT 对象,放入自己的缓存,TGT 对象的 ID 就是 cookie 的值。 当 HTTP 再次请求到来时,如果传过来的有 CAS 生成的 cookie,则 CAS 以此 cookie 值为 key 查询缓存中有无 TGT,如果有,说明用户之前登录过,如果没有,则用户需要重新登录。

TICKET-GRANTING COOKIE(TGC)

存放用户身份认证凭证的 cookie,在浏览器和 CAS Server 间通讯时使用,并且只能基于安全通道传输(Https),是 CAS Server 用来明确用户身份的凭证。

SERVICE TICKET(ST)

服务票据,服务的惟一标识码 , 由 CAS Server 发出( Http 传送),用户访问 Service 时,Service 发现用户没有 ST,则要求用户去 CAS 获取 ST。

存在的问题

四方认证与AJAX

前文已经介绍了 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、认证登录等一系列的逻辑似乎都没有办法继续进行了。

后端

0 思路

  1. Spring Boot 使用 cas-client-support-springboot 整合CAS
  2. 后端需要增加一个专门用来跳转页面的 Controller,只需能实现根据传入的参数(要跳转的URL)跳转到对应的页面即可。这个跳转的作用主要在于认证通过后返回前端页面,并建立会话,同时需要将会话的JSESSIONID放在 URL 中。
  3. 在尽量减少侵入的原则下,不对 CAS 本身的代码进行修改,而是在认证过滤之前增加一个自定义的过滤器,将原有的返回 302 重定向状态改为返回 JSON 数据。返回的数据应包括 CAS Client 中已定义的跳转 Controller地址,用于认证通过后返回到跳转页面的方法并跳回前端页面。

1 安装依赖

<!-- 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>

2 设置application配置项

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

3 新建配置属性映射文件

CASEnable.java
@Component
@Data
public class CASEnable {
    // 控制开关 true:启用登录验证  false:不启用
    @Value("${cas-enable}")
    private Boolean enable;
}
CASUrls.java
@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;
}

4 新增认证和登录接口

LoginController.java认证和登录接口
@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为Sa-Token开源权限框架中的鉴权工具类,此处仅为示例,具体根据实际情况用自己的登录鉴权方法
				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());
		}
	}
}
AjaxJson.java接口统一返回体
/**
 * 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
				+ "}";
	}
}

5 新建自定义CAS重定向策略文件

CustomAuthRedirectStrategy.java
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());
        if (ObjectUtils.isEmpty(request.getParameterMap())) {
            redirectUrl = redirectUrl + URLEncoder.encode("?fromfront=false", CharsetUtil.UTF_8);
        } else {
            redirectUrl = redirectUrl + URLEncoder.encode("&fromfront=false", CharsetUtil.UTF_8);
        }
        out.write(AjaxJson.getNotLogin().setData("\"" + redirectUrl + "\"").toString());
    }
}

6 新建CAS配置类文件

CASConfig.java

可根据需要对四个方法进行重写

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);
    }
}

前端

0 思路

①判断是否存在sessionId或cookie
②如果存在,将sessionId保存到cookie,sessionId优先级更高,如果sessionId与当前已存在的cookie不一致,用sessionId替换掉当前cookie,并开始走登录接口(dologin)。
③如果都不存在,则走认证接口(auth),前后端约定通过代码为401的响应返回重定向地址,通过前端进行跳转。

1 安装js-cookie

npm i js-cookie

2 封装登录函数

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接口
  }
},

3 设置监听

  watch: {
    '$route'(to,from) {
      this.login()
    }
  },

4 配置响应拦截器 约定重定向

要注意约定的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博客

  • 5
    点赞
  • 49
    收藏
    觉得还不错? 一键收藏
  • 8
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值