与其比较飞行的距离,以怎样的姿态飞,飞过哪里才更加重要
文章目录
前言
以前我曾经写过一篇关于前后端分离架构项目集成单点登录的文章,在现在看来其实单点登录还是很实用的,了解了cas的流程甚至可以自己实现一个简易版本的单点登录服务器。
不过本篇文章注重cas客户端源码的解析,如果有项目需要集成第三方的cas服务器,那么可以深入的了解cas客户端的代码,熟悉之后,不仅集成,扩展也会变得简单,相信会有进步。
一、Cas过滤器调用顺序
Cas客户端的调用流程主要有几个过滤器实现:
- CasSingleSignOutFilter
- CasValidationFilter
- CasAuthenticationFilter
- CasHttpServletRequestWrapperFilter
- CasAssertionThreadLocalFilter
这5个过滤器的调用顺序之上而下依次执行,在实际开发中可以doFilter方法处打断点进行调试。
二、Cas客户端过滤器的配置方式
@Configuration
public class CasConfig {
//配置单点退出Filter
@Bean
public FilterRegistrationBean SingleSignOutFilterRegistrationBean(){
FilterRegistrationBean singleSignOutFilter = new FilterRegistrationBean();
singleSignOutFilter.setFilter(new SingleSignOutFilter());
List<String> urlPatterns = new ArrayList<String>();
urlPatterns.add("/*");// 设置匹配的url
singleSignOutFilter.setUrlPatterns(urlPatterns);
return singleSignOutFilter;
}
//配置认证Filter
@Bean
public FilterRegistrationBean authenticationFilterRegistrationBean() {
FilterRegistrationBean authenticationFilter = new FilterRegistrationBean();
authenticationFilter.setFilter(new AuthenticationFilter());
Map<String, String> initParameters = new HashMap<String, String>();
initParameters.put("casServerLoginUrl", "https://authserver.hainanu.edu.cn/authserver/login");
initParameters.put("serverName", "http://f27714p111.imdo.co/");
//CAS过滤器白名单的设置,不同版本名称不同,可点进AuthenticationFilter进行查看
// initParameters.put("casWhiteUrl","/baseManage/login,/baseManage/casLogin");
// ,/baseManage/casLogin
initParameters.put("casWhiteUrl","/baseManage/login,/baseManage/appointmentAttendanceController/*.*,/baseManage/courseInfoController/*.*,/baseManage/coursePlanControll/*.*,/baseManage/courseTimetableAttendanceController/*.*,/baseManage/courseTimetableController/*.*,/baseManage/excel/*.*,/baseManage/fabricManagement/*.*,/baseManage/homeController/*.*,/baseManage/laboratoryBuildingController/*.*,/baseManage/laboratoryBuildingFloorController/*.*,/baseManage/laboratoryDirectorController/*.*,/baseManage/laboratoryGatewayController/*.*,/baseManage/laboratoryInfoController/*.*,/baseManage/laboratoryRuleController/*.*,/baseManage/laboratoryStationController/*.*,/baseManage/laboratoryUseRecordController/*.*,/baseManage/lessonTimeController/*.*,/baseManage/loginController/*.*,/baseManage/myCourseTimetableController/*.*,/baseManage/numberStatisticsController/*.*,/baseManage/roleController/*.*,/baseManage/schoolDistrictController/*.*,/baseManage/semesterController/*.*,/baseManage/studentInfoController/*.*,/baseManage/systemController/*.*,/baseManage/teacherInfoController/*.*,/baseManage/teacherLogInformationController/*.*,/baseManage/userController/*.*,/baseManage/userExternalInfoController/*.*");
authenticationFilter.setInitParameters(initParameters);
authenticationFilter.setOrder(2);
List<String> urlPatterns = new ArrayList<String>();
urlPatterns.add("/*");// 设置匹配的url
authenticationFilter.setUrlPatterns(urlPatterns);
return authenticationFilter;
}
//配置ticket验证Filter
@Bean
public FilterRegistrationBean ValidationFilterRegistrationBean(){
FilterRegistrationBean authenticationFilter = new FilterRegistrationBean();
authenticationFilter.setFilter(new Cas20ProxyReceivingTicketValidationFilter());
Map<String, String> initParameters = new HashMap<String, String>();
initParameters.put("casServerUrlPrefix", "https://authserver.hainanu.edu.cn/authserver");
initParameters.put("serverName", "http://f27714p111.imdo.co/");
authenticationFilter.setInitParameters(initParameters);
authenticationFilter.setOrder(1);
List<String> urlPatterns = new ArrayList<String>();
urlPatterns.add("/*");// 设置匹配的url
authenticationFilter.setUrlPatterns(urlPatterns);
return authenticationFilter;
}
//配置获取用户信息的Filter
//request.getRemoteUser()
@Bean
public FilterRegistrationBean casHttpServletRequestWrapperFilter(){
FilterRegistrationBean authenticationFilter = new FilterRegistrationBean();
authenticationFilter.setFilter(new HttpServletRequestWrapperFilter());
authenticationFilter.setOrder(3);
List<String> urlPatterns = new ArrayList<String>();
urlPatterns.add("/*");// 设置匹配的url
authenticationFilter.setUrlPatterns(urlPatterns);
return authenticationFilter;
}
}
将这个配置文件复制到项目中,按自己的实际情况进行参数的修改,那么cas客户端的过滤器就算集成到项目中了,想要了解专门前后端分离架构项目的对接流程可以看我以前写的文章。
三、Cas过滤器源码分析
1、CasSingleSignOutFilter
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
//底层原理,最终会调用request.getParameter(parameter)方法获取有无ticket的值,如果有进入下面记录session
//cas服务器传来ticket,说明这个用户在本系统是首次登录
if (handler.isTokenRequest(request)) {
//根据ticket记录这个session
handler.recordSession(request);
} else {
//如果是post请求并且request中有logoutRequest参数则进入退出登录的方法
if (handler.isLogoutRequest(request)) {
handler.destroySession(request);
//退出之后不用走到下一个过滤器了
return;
}
this.log.trace("Ignoring URI " + request.getRequestURI());
}
//如果不是退出的请求,进入到下一个Filter
filterChain.doFilter(servletRequest, servletResponse);
}
这个过滤器负责记录sesssion以及退出登录的功能,这里先分析记录session的功能。
public static String safeGetParameter(HttpServletRequest request, String parameter) {
if ("POST".equals(request.getMethod()) && "logoutRequest".equals(parameter)) {
LOG.debug("safeGetParameter called on a POST HttpServletRequest for LogoutRequest. Cannot complete check safely. Reverting to standard behavior for this Parameter");
return request.getParameter(parameter);
} else {
return request.getQueryString() != null && request.getQueryString().indexOf(parameter) != -1 ? request.getParameter(parameter) : null;
}
}
isTokenRequest(request)最终会调用request.getParameter(“ticket”)方法获取有无ticket的值,如果有ticket值说明用户是第一次登录这个客户端并且此前已经到cas服务器上登录过了,因为如果没有到cas服务器上登录的话,cas服务器是不会给客户端发送ticket的。
如果有ticket值那么使用recordSession(request)方法记录session。
public void recordSession(HttpServletRequest request) {
//request.getSession(false); 获得session,如果不存在则返回null
//request.getSession(true); 获得session,如果不存在则新建一个session并返回,等同于request.getSession();
HttpSession session = request.getSession(true);
//这里的token还是ticket的值
String token = CommonUtils.safeGetParameter(request, this.artifactParameterName);
if (this.log.isDebugEnabled()) {
this.log.debug("Recording session for token " + token);
}
try {
this.sessionMappingStorage.removeBySessionById(session.getId());
} catch (Exception var5) {
}
//记录到内部的sessionMappingStorage中
this.sessionMappingStorage.addSessionById(token, session);
}
sessionMappingStorage内部结构如下
private final Map<String, HttpSession> MANAGED_SESSIONS = new HashMap();
private final Map<String, String> ID_TO_SESSION_KEY_MAPPING = new HashMap();
private final Log log = LogFactory.getLog(this.getClass());
public HashMapBackedSessionMappingStorage() {
}
public synchronized void addSessionById(String mappingId, HttpSession session) {
this.ID_TO_SESSION_KEY_MAPPING.put(session.getId(), mappingId);
this.MANAGED_SESSIONS.put(mappingId, session);
}
其实就是将ticket和session记录到内部的一个sessionMappingStorage容器中,记录到容器中最终的目的是为了做退出的功能。
分析完记录session的功能,接下来是退出功能
如果不是记录Session的请求,那么进入else代码块,调用isLogoutRequest(request)方法,判断是否是退出请求,最终还是调用request.getParameter(“logoutRequest”)方法获取有无logoutRequest的值即可,这个logoutRequest的值里面包含服务器在我们第一次登录客户端时传给我们的ticket值。
public boolean isLogoutRequest(HttpServletRequest request) {
return "POST".equals(request.getMethod()) && !this.isMultipartRequest(request) && CommonUtils.isNotBlank(CommonUtils.safeGetParameter(request, this.logoutParameterName));
}
下面是真正销毁session的方法
public void destroySession(HttpServletRequest request) {
String logoutMessage = CommonUtils.safeGetParameter(request, this.logoutParameterName);
if (this.log.isTraceEnabled()) {
this.log.trace("Logout request:\n" + logoutMessage);
}
//从logoutMessage中解析出ticket的值,就是ST,然后我们就可以移除session了
String token = XmlUtils.getTextForElement(logoutMessage, "SessionIndex");
if (CommonUtils.isNotBlank(token)) {
//移除session的值。
HttpSession session = this.sessionMappingStorage.removeSessionByMappingId(token);
if (session != null) {
String sessionID = session.getId();
if (this.log.isDebugEnabled()) {
this.log.debug("Invalidating session [" + sessionID + "] for token [" + token + "]");
}
try {
session.invalidate();
} catch (IllegalStateException var7) {
this.log.debug("Error invalidating session.", var7);
}
}
}
}
sessionMappingStorage.removeSessionByMappingId(token);移除后,对于非前后端分离的项目,用户就相当于退出了。
logoutMessage的消息为xml,示列如下:
<samlp:LogoutRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="LR-4-mt3j3uZ0yd1TASsSyBAXLoEN" Version="2.0" IssueInstant="2020-07-23T18:07:08Z">
<saml:NameID
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">@NOT_USED@
</saml:NameID>
<samlp:SessionIndex>ST-6-pcFgrWGzkDwaTjKQkkSzmAYQfzYA013935-PC</samlp:SessionIndex>
</samlp:LogoutRequest>
2、CasValidationFilter
public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
if (this.preFilter(servletRequest, servletResponse, filterChain)) {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
String ticket = CommonUtils.safeGetParameter(request, this.getArtifactParameterName());
//如果有ticket的值,说明用户首次登录该系统,并且已经在cas服务器完成了登录,此时session中还没有_const_cas_assertion_这个属性,进入if代码块。
if (CommonUtils.isNotBlank(ticket)) {
if (this.log.isDebugEnabled()) {
this.log.debug("Attempting to validate ticket: " + ticket);
}
try {
//这里非常关键,就是将cas服务器给我们的ticket再到cas服务器中校验,如果校验成功,那么就能根据服务器返回的信息构建一个Assertion对象。
Assertion assertion = this.ticketValidator.validate(ticket, this.constructServiceUrl(request, response));
if (this.log.isDebugEnabled()) {
this.log.debug("Successfully authenticated user: " + assertion.getPrincipal().getName());
}
//到这里,说明ticket是正确的,校验成功,那么将assertion对象存储到request和session对象中,方便下次使用。
request.setAttribute("_const_cas_assertion_", assertion);
if (this.useSession) {
request.getSession().setAttribute("_const_cas_assertion_", assertion);
}
//钩子函数,我们可以重写它来完成我们的业务,当然也可以不写。
this.onSuccessfulValidation(request, response, assertion);
//redirectAfterValidation默认为false,不用管
if (this.redirectAfterValidation) {
this.log.debug("Redirecting after successful ticket validation.");
response.sendRedirect(this.constructServiceUrl(request, response));
return;
}
} catch (TicketValidationException var8) {
response.setStatus(403);
this.log.warn(var8, var8);
this.onFailedValidation(request, response);
if (this.exceptionOnValidationFailure) {
throw new ServletException(var8);
}
return;
}
}
//如果不是第一次登录或者没有到cas服务器完成登录的请求,直接进入到下一个Filter
filterChain.doFilter(request, response);
}
}
这个Filter是专门处理用户第一次登录客户端的情况,并且此时已经登录了cas服务器,这样cas服务器才会在用户第一次登录系统的时候返回ticket信息。
钩子函数可以这样重写,可以用来处理第一次登录后的操作。
//配置ticket验证Filter
@Bean
public FilterRegistrationBean ValidationFilterRegistrationBean(){
FilterRegistrationBean authenticationFilter = new FilterRegistrationBean();
authenticationFilter.setFilter(new Cas20ProxyReceivingTicketValidationFilter(){
@Override
protected void onSuccessfulValidation(HttpServletRequest request, HttpServletResponse response, Assertion assertion) {
//在这里可以写自己的业务代码
super.onSuccessfulValidation(request, response, assertion);
}
});
Map<String, String> initParameters = new HashMap<String, String>();
initParameters.put("casServerUrlPrefix", "https://authserver.hainanu.edu.cn/authserver");
initParameters.put("serverName", "http://f27714p111.imdo.co/");
authenticationFilter.setInitParameters(initParameters);
authenticationFilter.setOrder(1);
List<String> urlPatterns = new ArrayList<String>();
urlPatterns.add("/*");// 设置匹配的url
authenticationFilter.setUrlPatterns(urlPatterns);
return authenticationFilter;
}
3、AuthenticationFilter
public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
HttpSession session = request.getSession(false);
//如果是第一次登录或者是已经登录了的请求,session里面就会有有_const_cas_assertion_这个对象了,因此可以获取到assertion对象
Assertion assertion = session != null ? (Assertion)session.getAttribute("_const_cas_assertion_") : null;
if (assertion != null) {
//如果assertion对象不为空,那么到下一个Filter,这里可以说明如果用户成功登录系统后,那么后续的请求也不用再访问cas服务器了。
filterChain.doFilter(request, response);
} else {
//如果assertion对象为空,说明这个用户是本系统首次登录,但是它没有在cas服务器登录过,才会走到这里,所以session中没有assertion对象,因此这个请求是需要到cas服务器去进行登录的。
String serviceUrl = this.constructServiceUrl(request, response);
String ticket = CommonUtils.safeGetParameter(request, this.getArtifactParameterName());
boolean wasGatewayed = this.gatewayStorage.hasGatewayedAlready(request, serviceUrl);
String[] whiteUrls = this.casWhiteUrl.split(",");
boolean isWhite = false;
//在这里判断白名单,如果在白名单之内,那还是让你通过,不会让你重定向
for(int i = 0; i < whiteUrls.length; ++i) {
if (request.getRequestURI().toString().matches(whiteUrls[i])) {
isWhite = true;
break;
}
}
//!isWhite如果是白名单请求不会进入if代码块。
if (!CommonUtils.isNotBlank(ticket) && !wasGatewayed && !isWhite) {
this.log.debug("no ticket and no assertion found");
String modifiedServiceUrl;
if (this.gateway) {
this.log.debug("setting gateway attribute in session");
modifiedServiceUrl = this.gatewayStorage.storeGatewayInformation(request, serviceUrl);
} else {
modifiedServiceUrl = serviceUrl;
}
if (this.log.isDebugEnabled()) {
this.log.debug("Constructed service url: " + modifiedServiceUrl);
}
//构建cas服务器的登录地址
String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl, this.getServiceParameterName(), modifiedServiceUrl, this.renew, this.gateway);
if (this.log.isDebugEnabled()) {
this.log.debug("redirecting to \"" + urlToRedirectTo + "\"");
}
response.setContentType("text/html;charset=UTF-8");
//urlToRedirectTo就是cas服务器的登录地址,这里直接重定向获取,如果前后端分离结构,我们不想重定向的话,可以自定义AuthenticationFilter,然后继承AuthenticationFilter类,就可以重写doFilter这个方法,然后只修改else代码块中的代码即可,通常的做法是返回错误代码给前端,让前端去进行重定向
//需要注意不同版本的AuthenticationFilter类这里写法不一样,但是大同小异。
response.getWriter().write("<script languge='javascript'>window.location.href='" + urlToRedirectTo + "'</script>");
} else {
//如果这个请求是白名单的请求,则不需要进行登录,进入下一个Filter
filterChain.doFilter(request, response);
}
}
}
配置白名单的方法
//配置认证Filter
@Bean
public FilterRegistrationBean authenticationFilterRegistrationBean() {
FilterRegistrationBean authenticationFilter = new FilterRegistrationBean();
authenticationFilter.setFilter(new AuthenticationFilter());
Map<String, String> initParameters = new HashMap<String, String>();
initParameters.put("casServerLoginUrl", "https://authserver.hainanu.edu.cn/authserver/login");
initParameters.put("serverName", "http://f27714p111.imdo.co/");
//在这里配置白名单信息
initParameters.put("casWhiteUrl","/baseManage/login,/baseManage/appointmentAttendanceController/*.*,/baseManage/courseInfoController/*.*,/baseManage/coursePlanControll/*.*,/baseManage/courseTimetableAttendanceController/*.*,/baseManage/courseTimetableController/*.*,/baseManage/excel/*.*,/baseManage/fabricManagement/*.*,/baseManage/homeController/*.*,/baseManage/laboratoryBuildingController/*.*,/baseManage/laboratoryBuildingFloorController/*.*,/baseManage/laboratoryDirectorController/*.*,/baseManage/laboratoryGatewayController/*.*,/baseManage/laboratoryInfoController/*.*,/baseManage/laboratoryRuleController/*.*,/baseManage/laboratoryStationController/*.*,/baseManage/laboratoryUseRecordController/*.*,/baseManage/lessonTimeController/*.*,/baseManage/loginController/*.*,/baseManage/myCourseTimetableController/*.*,/baseManage/numberStatisticsController/*.*,/baseManage/roleController/*.*,/baseManage/schoolDistrictController/*.*,/baseManage/semesterController/*.*,/baseManage/studentInfoController/*.*,/baseManage/systemController/*.*,/baseManage/teacherInfoController/*.*,/baseManage/teacherLogInformationController/*.*,/baseManage/userController/*.*,/baseManage/userExternalInfoController/*.*");
authenticationFilter.setInitParameters(initParameters);
authenticationFilter.setOrder(2);
List<String> urlPatterns = new ArrayList<String>();
urlPatterns.add("/*");// 设置匹配的url
authenticationFilter.setUrlPatterns(urlPatterns);
return authenticationFilter;
}
4、HttpServletRequestWrapperFilter
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
AttributePrincipal principal = this.retrievePrincipalFromSessionOrRequest(servletRequest);
//进行简单包装并传递给下一个Filter
filterChain.doFilter(new HttpServletRequestWrapperFilter.CasHttpServletRequestWrapper((HttpServletRequest)servletRequest, principal), servletResponse);
}
是一个简单的包装,将Assertion对象中的Principal属性封装到request中,方便我们获取用户信息。
在代码中可以使用request.getRemoteUser();、request.getUserPrincipal();、request.isUserInRole();这三个方法来获取用户信息。
获取Assertion对象中的Principal属性返回
protected AttributePrincipal retrievePrincipalFromSessionOrRequest(ServletRequest servletRequest) {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpSession session = request.getSession(false);
Assertion assertion = (Assertion)((Assertion)(session == null ? request.getAttribute("_const_cas_assertion_") : session.getAttribute("_const_cas_assertion_")));
return assertion == null ? null : assertion.getPrincipal();
}
进行简单包装
final class CasHttpServletRequestWrapper extends HttpServletRequestWrapper {
private final AttributePrincipal principal;
CasHttpServletRequestWrapper(HttpServletRequest request, AttributePrincipal principal) {
super(request);
this.principal = principal;
}
public Principal getUserPrincipal() {
return this.principal;
}
}
5、AssertionThreadLocalFilter
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpSession session = request.getSession(false);
Assertion assertion = (Assertion)((Assertion)(session == null ? request.getAttribute("_const_cas_assertion_") : session.getAttribute("_const_cas_assertion_")));
try {
AssertionHolder.setAssertion(assertion);
filterChain.doFilter(servletRequest, servletResponse);
} finally {
AssertionHolder.clear();
}
}
从Request中取出用户信息,封装到AssertionHolder中,以便其他地方(无Request对象)也可以获取到当前用户信息。
获取用户信息方法
Assertion userInfo=AssertionHolder.getAssertion();
最后看一眼Assertion对象有什么:认证的类型,是否第一次登录,认证日期,认证的处理器
四、Cas单点退出功能
cas不仅能完成单点登录,还能完成单点退出。
我们向cas服务端发送退出请求的url如下,需要带上回调地址,这样就能完成单点退出。
@RequestMapping("/casLogout")
public void casLogout(HttpSession session,HttpServletResponse response) throws IOException {
//先执行业务系统的退出功能,这里省略了
//再执行Cas的单点退出功能
//注销session
session.invalidate();
// ids的退出地址,ids6.wisedu.com为ids的域名 authserver为ids的上下文,logout为固定值
String casLogoutURL = "https://authserver.hainanu.edu.cn/authserver/logout";
// service后面带的参数为应用的访问地址,需要使用URLEncoder进行编码
String redirectURL = casLogoutURL + "?service=" + URLEncoder.encode("http://f27714p111.imdo.co/casLogin");
//如果是前后端分离,这个接口不需要写,因为重定向会失效,而是直接将redirectURL交给前端,一旦退出直接让前端重定向这个URL即可。
response.sendRedirect(casLogoutURL);
}
原理也可以从CasSingleSignOutFilter类的源码可以看出来。从退出逻辑可以看出,当我们向cas服务器发送退出请求后,cas服务器就会将ticket传给我们进行退出,说明cas服务器存储了我们第一次登录系统时的ticket信息。
当我们登录完cas服务器后,通常cas服务器会在浏览器存储一个cookie叫做CASTGC,这个CASTGC就是用来进行关联我们的ticket的。
服务器通过CASTGC关联我们的ticket信息,这样cas服务器就能查找到CASTGC这个cookie关联的所有系统的所有ticket,从而向所有集成的客户端来发送退出的请求。
五、为什么Cas不适合前后端分离架构
从源码可以看出,如果你的session中没有包含Assertion对象,那么cas客户端的拦截器就会认为你是未登录的状态,而前后端分离架构已经是不需要session对象了,因为每一个ajax请求都会被认为是一个新的请求,sessionId都是不一样的,所以不适合集成前后端分离架构。
六、前后端分离架构可以怎么样解决
首先不处理肯定是不行的,因为ajax请求每一次请求都会被认为是一次新的请求,sessionId每次都不同,客户端的cas拦截器会认为这次请求没有登录,就会被拦截。
第一种比较好的方法是一旦登录成功后,这样回调的地址就会获取到一个有用户信息的sessionId,那么后端将这个sessionId传给前端进行保存,前端的后续Ajax请求都携带上这个sessionId来访问后端就不会被拦截的,这样不仅能实现单点登录,也能实现单点退出。但是这种方式也违背了前后端分离架构的设计理念,可以接受的情况下使用。
还有第二种方法是除了回调方法的接口和登录接口需要被拦截以外,项目的其它接口都可以配置cas的AuthenticationFilter拦截器进行放行,这样我们就只是获取一次cas服务器的用户信息,然后让前端通过这个用户信息使用默认密码登录我们的系统,后续的请求cas的拦截器都放行。这样的好处是遵循前后端分离的设计理念,坏处是不能实现单点退出的功能,因为把绝大部分请求都放行了,即使退出了系统也感知不到了。
如果对单点退出的功能不在意的话,选择第二种方法是比较好的,如果需要单点退出的功能,选择第一种方案。
具体的对接方式也可以看我以前写的文章。
七、自己编写一个单点登录服务器的思路
如果熟悉了cas客户端的源码的话,我们也可以实现一个属于自己的单点登录服务器。
为了适配前后端分离架构我们不应该使用session的功能了,所以实现逻辑和cas方式稍有不同,这里提供一下实现思路,单点登录在这里简称为sso。
sso服务器希望的是尽量不要约束客户端原有的代码的逻辑,让其它的系统能轻量化的接入单点登录和退出的功能。
单点登录功能的实现
单点登录一共会有两种情况,这两种情况我都是根据前后端分离的架构来描述的,当然非前后端分离的项目也同理。
第一种情况,用户已经在我们的sso服务器完成登录,然后再到集成的某一个客户端进行登录。
第一步,用户先到单点登录服务器登录,那么浏览器已经存储了sso服务器的token信息,这个token信息包含用户的Id信息,其它信息也可以有。
第二步,用户访问前后端分离项目的前端登录页面,前端在这里需要调整,改为当用户访问登录界面后,直接通过Ajax访问客户端的登录接口,以前cas中因为session中已经存在用户信息,所以客户端不需要再去sso服务器中获取用户信息,但我们这里已经不使用session了,所以我们要主动访问sso服务器获取用户的信息。
第三步,在客户端登录接口中,客户端需要前端传来一个ticket参数,这个参数用来获取用户详细信息。这里没有ticket,所以这里重定向到sso服务器的登录接口,然后带上回调地址service,这里的回调地址选择前端登录页面的地址。
第四步,sso服务器接收到客户端的登录请求,访问sso服务器的单点登录接口,首先先获取浏览器cookie的值,发现有token,说明用户已经在sso服务器完成登录,那么生成一个服务器能够识别的ticket,我选择的是jwt形式的ticket,里面带有用户id的标识,然后重定向到回调地址,并且地址后面拼接上ticket的参数。
第五步,回调到前后端分离项目的前端登录页面,这里前端需要先校验一下地址栏是否有ticket参数,这里是有的,那么截取ticket参数下来,带着ticket参数通过Ajax访问客户端的登录接口。
第六步,回调到前后端分离项目的前端登录页面,这里前端需要先校验一下地址栏是否有ticket参数,这里是有的,那么截取ticket参数下来,带着ticket参数通过Ajax访问客户端的登录接口。
第七步,在客户端登录接口中,这里因为接收到了ticket信息,所以就不用像第三步那样重定向到sso服务器的登录接口,而是直接通过远程Http请求访问sso服务器的获取用户详细信息接口,参数为ticket信息。
第八步,在sso服务器的用户详细信息接口中,这里因为接收到了ticket信息,然后校验一下ticket是否正确,如果正确那么解析出用户id信息,然后查询数据库获取到用户的详细信息,然后将信息返回给客户端。
第九步,在客户端登录接口中,获取到了用户的详细信息,然后通过这些信息进行本地系统的登录,登录完成后就可以将用户登录成功的信息返回给前端了,至此单点登录完成。
第二种情况,用户没有到sso服务器登录,就直接访问集成的某一个客户端进行登录。
第一步,用户没有先到sso服务器登录,而是首先访问前后端分离项目的前端登录页面,然后前端页面就会通过Ajax请求访问客户端的登录接口。
第二步,在客户端登录接口中,客户端需要前端传来一个ticket参数,这个参数用来获取用户详细信息。这里没有ticket,所以这里重定向到sso服务器的登录接口,然后带上回调地址service,这里的回调地址选择前端登录页面的地址。这里和第一种情况的第三步一样。
第三步,sso服务器接收到客户端的登录请求,首先先获取浏览器cookie的值,发现没有token,说明用户没有在sso服务器完成登录,因此重定向到sso服务器登录界面进行登录,参数带上回调地址service,这个参数是客户端传来的。那么生成一个服务器能够识别的ticket,我选择的是jwt形式的ticket,里面带有用户id的标识,然后重定向到回调地址,并且地址后面拼接上ticket的参数。
第四步,用户在sso服务器登录界面填写正确的用户名和密码,然后进行登录,这里的登录访问的是原始的登录方法,不是客户端访问的登录接口。这里前端需要先校验一下地址栏是否有service参数,如果有说明是子系统请求的登录。那么登录成功后,生成一个token,然后存储到浏览器的cookie中,这样后面就能判断用户在判断用户在sso服务器已经完成登录。接下来判断有无service参数,如果没有则说明用户单纯的想登录sso服务器,那么跳转到sso服务器的首页。我们这里是有service参数的,然后就可以进行重定向到sso服务器的登录接口了,这里的登录接口是客户端访问的登录接口,目的是模仿客户端的登录请求。
在上一步最后我们模仿了客户端的登录请求,那么接下来的流程就是第一种情况的第四步了,至此单点登录完成。
终上所述可以得知sso服务器需要至少有三个接口,第一个是原始登录接口,第二个是获取用户信息的接口,第三个是sso登录接口,原始登录接口主要是要将自定义的token写到浏览器的cookie中,这是单点登录的关键。
客户端需要遵循服务器的规则,前端需要进行修改,当用户访问前端登录界面的时候,前端不再显示登录界面,而是直接访问客户端的登录接口。客户端只需要提供一个单点登录接口即可,这样就能实现单点登录的功能。
单点退出功能的实现
上面的思路仅实现的是单点登录的功能,如果有单点退出的需要,那么在单点登录的基础上还需要进行拓展,可以模仿cas的方式。
第一步,扩展sso服务器的单点登录方法,在这个方法中如果浏览器已经有token信息,那么会生成一个ticket。在这里进行扩展,客户端需要再传一个退出登录的回调地址过来,然后将ticket和浏览器cookie中的token以及客户端退出的回调接口地址关联起来。返回信息不变,还是只返回ticket信息。
sso服务端关联的数据结构可以是Map<token,Map<客户端退出的回调接口地址,List<“ticket”>>
第二步,客户端接收到ticket和用户的详细信息,然后利用用户信息进行登录,和以前不同的是客户端也需要记录ticket和用户登录成功后的信息,这里假设用户登录成功后的信息存储在redis中,为的是以后的单点退出,最后将登录成功后的信息返回给前端,至此单点登录完成。
那么客户端关联的数据结构可以是Map<ticket,用户信息在redis中的唯一标识>
第三步,用户想要退出登录,那么访问服务器的退出登录接口,服务器在退出登录接口中先获取浏览器的token信息,然后就可以根据token获取到Map<客户端退出的回调接口地址,List<“ticket”>>这个数据结构了,那么服务器就可以通过远程http调用多个不同的客户端退出的回调接口地址,并给它们传List<“ticket”>的参数,这样客户端就可以根据ticket信息帮用户进行批量的退出了,在这里的例子中客户端需要将用户信息从redis中进行删除,至此单点退出完成。
终上所述可以得知在单点登录的基础上,sso服务器需要再提供一个用户退出的接口,然后需要修改单点登录接口的部分逻辑。客户端也需要再提供一个用户退出的接口,然后需要修改用户登录接口的部分逻辑。
控制登录token时间
sso服务器在生成token信息到浏览器时还可以指定cookie的过期时间,这样token过期,后面访问的系统就又要进行重新的登录了。
控制用户能访问什么系统
在sso服务器中,还可以规定用户能访问什么系统,新的系统集成进来可以注册到sso服务器的数据库中,如果没有注册到sso服务器的系统应该进行拒绝登录。
还可以规定用户能访问能访问什么系统,如果没有权限,那么即使用户已经登录了sso服务器,但他也不能访问这个系统,在用户访问单点登录的接口时sso服务器根据回调地址判断用户要登录哪个系统,如果无权限则返回无权限的信息给客户端即可。
总结
如果有其它的新的系统想集成进这个sso服务器的话,那么新的系统只需要按照上面客户端的逻辑进行代码编写,遵循sso服务器定义的规则,就能实现单点登录和单点退出的功能,并且兼容性也比cas的形式更好,没有使用到session的功能。
总结
以上就是Cas客户端源码的解析了,知道源码的逻辑后希望能够轻松地集成第三方的Cas服务器,甚至自己能实现一个简单的单点登录服务器,相信会有进步。