目录
转载请注明出处
CAS简介
CAS(Central Authentication Service,中央认证服务)是一种单点登录(SSO)协议,允许用户在一个系统中登录后,无需再次登录即可访问其他受信任的系统。以下是CAS认证系统的关键点介绍:
核心概念
- CAS Server:负责用户认证的中心服务器。用户首次登录时,CAS Server验证用户凭证(如用户名和密码)。
- CAS Client:集成到各个应用中的客户端库,用于与CAS Server通信,验证用户是否已登录。
- Ticket:CAS使用两种票据进行认证:
- TGT(Ticket Granting Ticket):用户登录成功后,CAS Server颁发给用户的长期票据。
- ST(Service Ticket):用户访问特定服务时,CAS Server颁发的短期票据。
认证流程
- 1.用户访问应用:用户尝试访问一个受保护的应用。
- 2.重定向到CAS Server:应用检测到用户未登录,将用户重定向到CAS Server。
- 3.用户登录:用户在CAS Server输入凭证进行登录。
- 4.颁发TGT:CAS Server验证凭证后,颁发TGT并存储在用户的浏览器中。
- 5.颁发ST:CAS Server生成ST并重定向用户回应用,附带ST。
- 6.验证ST:应用将ST发送到CAS Server进行验证。
- 7.访问应用:验证通过后,用户被允许访问应用。
优点
- 单点登录:用户只需登录一次,即可访问多个应用。
- 安全性:通过票据机制,减少了密码在网络中的传输次数。
- 集中管理:认证逻辑集中在CAS Server,便于管理和维护。
实现
- Java:CAS提供了Java客户端库,可以轻松集成到Java应用中。
- 其他语言:CAS也支持多种编程语言的客户端库,如PHP、Python等。
CAS认证客户端实现
实现思路
关于CAS的客户端实现有很多种方式, 可以自己编写过滤器实现关键步骤的对应跳转. 或者引用一些客户端来实现. 本文中后面的实现方式主要是基于 cas-client-autoconfig-support
来实现对接CAS的服务端, 这个实现中封装了和CAS服务端对接的相关逻辑. 但是也不能直接拿来就用需要稍微改动一下.
为什么说要稍微改动一下, 因为这个登录认证是基于 session
来实现的. 也就是当访问某个资源的时候, 如果没有认证需要在保持在同一个会话的前提下, 客户端重定向到CAS Server
进行登录认证操作. cas-client-autoconfig-support
这个实现已经封装了这些处理和操作, 并且默认将拦截全部的资源(/**
). 当请求 CAS Client 服务端
的时候, 它会自己验证有没有认证, 没有认证会跳转到服务端, 登录完成后会跳转回来.
但是这里面临一个问题, 现在大多数都是前后端分离的项目, 前端和和后端交互的时候基本都是Ajax的请求. 也就是前端的请求到CAS Client服务端
如果没有认证, 后端虽然会返回一个 重定向302
但是前端的页面是不会跳转的. 没办法触发重定向, 所以也就没办法跳转到 CAS Server
. 下面来处理一下这个问题.
刚才说 cas-client-autoconfig-support
默认会拦截全部的资源 (/**
), 我们就通过配置让他拦截一个固定的路径, 只有请求这个路径才会跳转到 CAS Server
去认证. 其他的接口放行. 对于这部分接口然后我们自己编写一个过滤器, 在过滤器里获取用户信息, 如果获取不到用户信息(未认证的情况), 后端直接返回前端一个状态码(401)
. 前端接受到这个状态码之后主动通过URL请求
后端的一个接口. 这个请求是URL请求方式. 然后后端服务就可以随意进行重定向, 包括跳转到CAS Server
等一系列操作, 当CAS Server
认证之后又会重定向到 CAS Client 服务端
, 然后我们再把这个请求重定向到用户最开始访问的页面即可.
这里我们主要介绍4个关键接口:
/cas-client/call-back
登录回调接口- 这个接口就是我们要配置被
cas-client-autoconfig-support
拦截的接口
- 这个接口就是我们要配置被
/cas-client/login?redirectUrl=登录成功后回调地址
- 这个接口是当前端收到
401
的时候, 需要URL请求
的登录接口, 不被cas-client-autoconfig-support
拦截
- 这个接口是当前端收到
/cas-client/logout?redirectUrl=登录退出后回调地址
- 这个接口是前端主动退出登录, 需要
UR请求
的接口, 不被cas-client-autoconfig-support
拦截
- 这个接口是前端主动退出登录, 需要
/cas-client/get-user
- 这个接口是供前端通过
AJAX请求
查询用户的接口 , 不被cas-client-autoconfig-support
拦截
- 这个接口是供前端通过
认证流程
- 1.用户访问页面, 前端首先会Ajax请求
/get-user
接口, 没有认证, 后端返回401
- 2.前端URL请求
/login?redirectUrl=/aaa/bbb
并设置登录成功的回调地址 - 3.后端获取到
/login?redirectUrl=/aaa/bbb
中的地址, 在后端服务生成一个随机ID(state)
, 将这个state
和/aaa/bbb
建立关系存起来 - 4.后端将请求重定向至
/cas-client/call-back
接口, 并将上一步生成的state
作为参数携带(/cas-client/call-back?state=123456
) - 5.
/cas-client/call-back?state=123456
会被cas-client-autoconfig-support
拦截, 这个时候就进入了CAS正常的登录流程. 重定向到CAS Server
登录, 并且框架会自动将被拦截的资源(/cas-client/call-back?state=123456
)携带. - 6.登录成功之后,
CAS Server
又会重定向到/cas-client/call-back?state=123456
这个时候. 因为已经登录认证过了, 请求不再被拦截会正常进入我们的业务逻辑. - 7.当已认证的请求进入
/cas-client/call-back?state=123456
后, 我们查询存储中state=123456
对应的回调地址/aaa/bbb
, 然后将请求重定向到(302)
到/aaa/bbb
- 8.当页面回到
/aaa/bbb
的时候已经被认证过. 前端再次 Ajax请求/get-user
接口就可以获取到用户信息了.
在上述流程中, 前端仅需要做第
1
2
和两个步骤. 剩下的都由后端来处理
在上述流程中, 第2
3
4
步骤中的传参不是必须的, 也就是state
对应回跳地址的这一步,
- 你可以写成一个固定页面, 比如系统的首页, 那么请求
/login?redirectUrl=/aaa/bbb
的时候redirectUrl=/aaa/bbb 就可以省略- 如果请求的地址比较短, 也可以直接将地址拼. 如
/cas-client/call-back?redirectUrl=/aaa/bbb
代码实现
pom.xml依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<artifactId>spring-boot-starter-logging</artifactId>
<groupId>org.springframework.boot</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>net.unicon.cas</groupId>
<artifactId>cas-client-autoconfig-support</artifactId>
<version>2.3.0-GA</version>
</dependency>
<!-- hutool 工具类, 非必须. 可以换成你喜欢的 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.35</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
相关配置
# ------------------------------- CAS单点登陆配置 start ---------------
# 本地服务地址
server.host=http://demo.test.cn
# 本地服务端口号
server.port=9090
# 登录成功后默认跳转地址
cas-filter.home-index-url=http://demo.test.cn
# CAS认证服务器前缀
cas.server-url-prefix=http://server.test.cn
# ------------------------------- CAS单点登陆配置 end -----------------
# ------------------------------- CAS单点登陆默认配置 start ------------
# CAS认证服务器登录地址
cas.server-login-url=${cas.server-url-prefix}/login
# CAS认证服务器登出地址
cas-filter.server-logout-url=${cas.server-url-prefix}/logout
# CAS认证客户端(本地)地址前缀
cas.client-host-url=${server.host}:${server.port}
# 协议类型
cas.validation-type=cas
# 鉴权地址
cas.authentication-url-patterns[0]=/cas-client/call-back
# ------------------------------- CAS单点登陆默认配置 end -------------
URL映射常量
我在这里没有写 controller, 上面说的登录、登出获取用户接口,都直接在 Filter中实现了
package com.project.util;
/**
* Url mapping constant
*
* @author Felix
* @date 2025/02/13
*/
public class UrlMappingConstant {
/**
* state
*/
public static final String CAS_LOGIN_STATE_KEY = "state";
/**
* Interceptor prefix
*/
public static final String INTERCEPTOR_PREFIX = "/cas-client/";
/**
* Login url
*/
public static final String CAS_LOGIN_URL = INTERCEPTOR_PREFIX + "login";
/**
* Login callback url
*/
public static final String CAS_LOGIN_CALLBACK_URL = INTERCEPTOR_PREFIX + "call-back";
/**
* Logout url
*/
public static final String CAS_LOGOUT_URL = INTERCEPTOR_PREFIX + "logout";
/**
* Get user url
*/
public static final String CAS_GET_USER_URL = INTERCEPTOR_PREFIX + "get-user";
}
核心Filter.java
package com.project.filter;
import cn.hutool.core.text.CharSequenceUtil;
import com.project.bean.CasUserInfo;
import com.project.util.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import javax.annotation.PostConstruct;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiConsumer;
import static com.project.util.UrlMappingConstant.*;
/**
* CAS Filter
*
* @author Felix
* @date 2025/02/14
*/
@Slf4j
@WebFilter(filterName = "com.server.CasFilter")
@Configuration
public class CasFilter implements Filter {
@Value("${cas-filter.home-index-url:}")
private String homeIndexUrl;
@Value("${cas-filter.server-logout-url:}")
private String casServerLogoutUrl;
@Value("${cas-filter.redirect-url-key:redirectUrl}")
private String redirectUrlKey;
/**
* URL HANDLER
*/
private final Map<String, BiConsumer<HttpServletRequest, HttpServletResponse>> filterHandlers = new HashMap<>(4);
@PostConstruct
private void initHandlers() {
log.info(">>> Init cas handler.");
filterHandlers.put(CAS_LOGIN_URL, this::login);
filterHandlers.put(CAS_LOGIN_CALLBACK_URL, this::loginCallBack);
filterHandlers.put(CAS_LOGOUT_URL, this::logout);
filterHandlers.put(CAS_GET_USER_URL, this::getUserInfo);
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String requestUri = CharSequenceUtil.trim(request.getRequestURI());
for (Map.Entry<String, BiConsumer<HttpServletRequest, HttpServletResponse>> entry : filterHandlers.entrySet()) {
String matchUri = entry.getKey();
if (FilterUtil.urlMatch(matchUri, requestUri)) {
BiConsumer<HttpServletRequest, HttpServletResponse> handler = entry.getValue();
handler.accept(request, response);
return;
}
}
filterChain.doFilter(request, response);
}
/**
* Log in
*
* @param request request
* @param response response
*/
private void login(HttpServletRequest request, HttpServletResponse response) {
String loginSuccessRedirectUrl = this.getWebRedirectUrl(request);
log.info(">>> Login success redirect url: {}", loginSuccessRedirectUrl);
String state = PrimaryKeyUtil.nextConfuseId();
CasCacheUtil.put(state, loginSuccessRedirectUrl);
String casServerLoginUrl = CAS_LOGIN_CALLBACK_URL + "?" + CAS_LOGIN_STATE_KEY + "=" + state;
log.info(">>> Login server callback url: {}", casServerLoginUrl);
FilterUtil.redirectPage(response, casServerLoginUrl);
}
/**
* Login callback
*
* @param request request
* @param response response
*/
private void loginCallBack(HttpServletRequest request, HttpServletResponse response) {
String loginSuccessRedirectUrl = homeIndexUrl;
String state = request.getParameter(CAS_LOGIN_STATE_KEY);
if (CharSequenceUtil.isNotBlank(state)) {
String loginSuccessRedirectUrlCache = CasCacheUtil.get(state);
if (CharSequenceUtil.isNotBlank(loginSuccessRedirectUrlCache)) {
loginSuccessRedirectUrl = loginSuccessRedirectUrlCache;
CasCacheUtil.del(state);
}
}
CasUserInfo userInfo = CasUserUtil.getUserInfoFromCas(request);
if (null == userInfo) {
FilterUtil.returnJson(response, HttpStatus.INTERNAL_SERVER_ERROR, ApiResult.error(String.valueOf(HttpStatus.INTERNAL_SERVER_ERROR.value()), "User resolution exception"));
} else {
log.info(">>> Current login user: {}", userInfo.getName());
log.info(">>> Login callback to url: {}", loginSuccessRedirectUrl);
FilterUtil.redirectPage(response, loginSuccessRedirectUrl);
}
}
/**
* logout
*
* @param request request
* @param response response
*/
private void logout(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession();
if (null != session) {
session.invalidate();
}
String logoutRedirectUrl = this.casServerLogoutUrl + "?service=" + this.getWebRedirectUrl(request);
log.info(">>> Logout redirect url: {}", logoutRedirectUrl);
FilterUtil.redirectPage(response, logoutRedirectUrl);
}
/**
* Get user information
*
* @param request request
* @param response response
*/
private void getUserInfo(HttpServletRequest request, HttpServletResponse response) {
CasUserInfo userInfo = CasUserUtil.getUserInfoFromCas(request);
if (null == userInfo) {
FilterUtil.returnJson(response, HttpStatus.UNAUTHORIZED, ApiResult.error(String.valueOf(HttpStatus.UNAUTHORIZED.value()), "Unauthorized"));
} else {
log.debug(">>> Current query user: {}", userInfo.getName());
FilterUtil.returnJson(response, HttpStatus.OK, ApiResult.ok(userInfo));
}
}
/**
* Gets the web redirect url
*
* @param request request
* @return {@link String }
*/
private String getWebRedirectUrl(HttpServletRequest request) {
String redirectUrl = request.getParameter(redirectUrlKey);
if (CharSequenceUtil.isBlank(redirectUrl)) {
redirectUrl = homeIndexUrl;
}
return redirectUrl;
}
}
CasUserUtil.java 用户解析工具类
package com.project.util;
import com.project.bean.CasUserInfo;
import lombok.extern.slf4j.Slf4j;
import org.jasig.cas.client.validation.Assertion;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Map;
/**
* CAS User Util
*
* @author Felix
* @date 2025/02/12
*/
@Slf4j
public class CasUserUtil {
/**
* Default session key of the cas client
*/
public static final String CAS_SESSION_KEY = "_const_cas_assertion_";
/**
* Obtain cas user information
*
* @param request request
* @return {@link CasUserInfo }
*/
public static CasUserInfo getUserInfoFromCas(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
Object object = session.getAttribute(CAS_SESSION_KEY);
if (null == object) {
return null;
}
Assertion assertion = (Assertion) object;
return buildCasUserInfoByCas(assertion);
}
/**
* Build cas user information
*
* @param assertion assertion
* @return {@link CasUserInfo }
*/
private static CasUserInfo buildCasUserInfoByCas(Assertion assertion) {
if (null == assertion) {
log.error(">>> The Cas does not obtain the user. Procedure ");
return null;
}
try {
CasUserInfo casUserInfo = new CasUserInfo();
String name = assertion.getPrincipal().getName();
Map<String, Object> attributes = assertion.getPrincipal().getAttributes();
casUserInfo.setName(name);
casUserInfo.setAttributes(attributes);
return casUserInfo;
} catch (Exception exp) {
log.error(">>> The user conversion error: ", exp);
return null;
}
}
}
FilterUtil.java
package com.project.util;
import cn.hutool.core.text.AntPathMatcher;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* Filter util
*
* @author Felix
* @date 2025/02/12
*/
@Slf4j
public class FilterUtil {
private static final AntPathMatcher ANT_PATH_MATCHER = new AntPathMatcher();
/**
* Url matching
*
* @param pattern pattern
* @param path path
* @return boolean
*/
public static boolean urlMatch(String pattern, String path) {
return ANT_PATH_MATCHER.match(pattern + "*", path);
}
/**
* Return response message
*
* @param response response
* @param status status
* @param result result
*/
public static void returnJson(HttpServletResponse response, HttpStatus status, ApiResult<Object> result) {
response.setStatus(status.value());
response.setCharacterEncoding("utf-8");
response.setContentType("application/json");
try {
PrintWriter pw = response.getWriter();
pw.write(JSONUtil.toJsonStr(result));
pw.flush();
} catch (IOException e) {
log.error(">>> Return json failure", e);
throw new RuntimeException(e);
}
}
/**
* Redirect page
*
* @param response response
* @param redirectUrl redirect url
*/
public static void redirectPage(HttpServletResponse response, String redirectUrl) {
try {
response.setStatus(HttpStatus.SEE_OTHER.value());
response.sendRedirect(redirectUrl);
} catch (IOException e) {
log.error(">>> Redirection failure", e);
throw new RuntimeException(e);
}
}
}
以上便是登录认证所需的关键代码片段了, 涉及的存储部分,请自行替换即可。
关于登出的流程比较简单, 直接重定向即可, 上面的 CasFilter.java
中已经包含了, 这里不再过多解释.