关于前后端分离的项目对接cas单点登录的一些问题

关于前后端分离的项目对接cas单点登录的一些问题

简单介绍一下情况

最近在公司内的一个不正经项目上遇到了一些问题,我们的项目是前后端分离的,在对接公司的cas单点登录时遇到了一些问,简单记录一下解决的方案,也不清楚这么做是不是对的。

本文会涉及 cas,单点登录,cookie,session

什么是cas

CAS,CAS全称为Central Authentication Service即中央认证服务,是一个单点登录的解决方案,具体的可以去github仓库看看

https://github.com/apereo/cas

具体的特征什么的可以参考这个

The following features are supported by the CAS project:

  • CAS v1, v2 and v3 Protocol
  • SAML v1 and v2 Protocol
  • OAuth v2 Protocol
  • OpenID & OpenID Connect Protocol
  • WS-Federation Passive Requestor Protocol
  • Authentication via JAAS, LDAP, RDBMS, X.509, Radius, SPNEGO, JWT, Remote, Apache Cassandra, Trusted, BASIC, Apache Shiro, MongoDb, Pac4J and more.
  • Delegated authentication to WS-FED, Facebook, Twitter, SAML IdP, OpenID, OpenID Connect, CAS and more.
  • Authorization via ABAC, Time/Date, REST, Internet2’s Grouper and more.
  • HA clustered deployments via Hazelcast, Ehcache, JPA, Apache Cassandra, Memcached, Apache Ignite, MongoDb, Redis, DynamoDb, Couchbase and more.
  • Application registration backed by JSON, LDAP, YAML, Apache Cassandra, JPA, Couchbase, MongoDb, DynamoDb, Redis and more.
  • Multifactor authentication via Duo Security, YubiKey, RSA, Google Authenticator, U2F, WebAuthn and more.
  • Administrative UIs to manage logging, monitoring, statistics, configuration, client registration and more.
  • Global and per-application user interface theme and branding.
  • Password management and password policy enforcement.
  • Deployment options using Apache Tomcat, Jetty, Undertow, packaged and running as Docker containers.

cas登录步骤

如果你去网上搜索cas的登录步骤大概会看到一张非常复杂的图片

然后下面列出123456789…..

看上去就很头疼,但是实际上不需要这么复杂的去理解

把大象放进冰箱只需要3步

  1. 用户访问我们的网站,此时发现用户没有登陆,跳转到cas服务器对应的地址/cas/login?service={backend_url}
  2. 用户在cas上进行登录
  3. 由cas跳转回我们的系统,此时由cas的客户端完成登录。

传统的对接方法

按照传统功夫的对接方法,我们需要在项目中配置cas客户端的拦截器,然后由cas的拦截器来验证我们的请求,进行验证和跳转。

在我个人的实践中发现其实cas拦截器的跳转功能可以由我们自己手动进行跳转,cas客户端的拦截器是基于session中是否存在const_cas_assertion参数进行拦截,而我们的系统需要进行一些手动的更改。所以在这里没有使用cas客户端的拦截器,使用自己编写的拦截器进行校验,然后发现session里取不到值,在查了一些资料之后发现从cas跳转回我们的系统之后必须使用cas客户端的拦截器进行拦截。

为什么必须用cas客户端的拦截器进行拦截,而不能自己在跳转后进行拦截呢,这就要深入一下cas跳转回我们的系统之后进行的验证过程了,cas跳转回自己的系统中的url上会带上一个参数ticket并写入一个名为CASTGC的cookie值,而cas的客户端会根据这些信息去验证是否登录。那么传统的cas对接只需要简单的配置一下cas的拦截器就好

如果后端使用springboot的话

参考 : https://github.com/apereo/java-cas-client#spring-boot-autoconfiguration

只需要在配置文件中加上

1
2
3
cas.server-url-prefix=https://cashost.com/cas
cas.server-login-url=https://cashost.com/cas/login
cas.client-host-url=https://casclient.com

然后在启动类上加上注解

1
2
3
4
@SpringBootApplication
@Controller
@EnableCasClient
public class MyApplication { .. }

就基本算是上可以使用了

前后端分离下的问题和我的解决方案

问题

首先一个问题就是哪怕cas登录了前后端涉及跨域问题,而标准的cas客户端验证是基于cookie和session的,如果不做任何处理的话会导致所有的请求都爆炸掉,后端也无法争取的拿到值。

第二个问题就是虽然拦截器能够拦截请求,但是会自动进行跳转,并且这个过程在前端通过跨域请求的情况下会出现问题,所以要想办法绕过这个过程由我们手动进行跳转。

解决

为了解决这两个问题呢机智的我想出了这么一个比较笨拙的方法。

cas客户端的拦截器只负责拦截一个接口,然后其他的接口交给我自己的拦截器进行控制,然后跳转的情况下只需要传递给cas服务器的service参数跳转回这个被cas客户端拦截的接口就好。在这个接口中获取对应的数据,然后后面的就简单了,可以将数据和一个sessionid存入缓存中,也可以生成jwt进行验证,然后想办法把这个sessionid或者jwt传给前端,然后前端后续的请求都在header中带上这个值。自己的拦截器只需要进行这些简单的判断并且在没有登陆的情况下返回401错误,然后前端进行跳转。

首先配置一下cas客户端的配置,只拦截我们手写的login接口

1
cas.validation-url-patterns=/api/login

然后前端直接跳转到这个接口,就会被重定向到cas登陆的界面

在cas系统中完成登录之后就会跳转回这个接口,cas客户端的拦截器回验证是否登陆,我们在这个接口中跳转到前端的页面,并将sessionid发送给前端,这里发送sessionid是通过url参数发送的,暂时没有想出什么其他的好方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@GetMapping("/login")
public void redirectView(HttpServletResponse response, HttpServletRequest request) {
HttpSession session = request.getSession();
Object casAssertion = session.getAttribute(CasConstants.CAS_SESSION_KEY);
Assertion assertion = (Assertion) casAssertion;
String sessionId = session.getId();
String userAccount = assertion.getPrincipal().getName();
UserDetailModel detail = userDetailService.getUserDetailByAccount(userAccount);
UserLoginModel cacheModel = new UserLoginModel(sessionId, userAccount, detail);
CasCacheUtil.put(sessionId, cacheModel);
log.info("用户登录: " + userAccount + " ,JSESSIONID: " + sessionId);
String url = casConfig.CLIENT_FRONT_URL + "?JSESSIONID=" + sessionId;
response.addHeader(CasConstants.AUTHORAZATION, session.getId());
try {
response.sendRedirect(url);
} catch (IOException e) {
e.printStackTrace();
}
}

然后前端之后的每次请求都会带上Authorization头部,我们自己的拦截器中进行验证。如果没有登陆则抛出401错误,前端拿到这个错误之后就跳转到登陆接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class LoginInterceptor extends HandlerInterceptorAdapter {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
// 放行登录
if (StringUtils.equalsIgnoreCase("/api/login",uri) ) {
return true;
}
String auth = request.getHeader(CasConstants.AUTHORAZATION);
if (auth == null) {
returnError(response);
return false;
}
UserLoginModel userLoginModel = AuthUtil.getUserLoginModel(request);
if (userLoginModel == null) {
returnError(response);
return false;
}
return true;
}

private void returnError(HttpServletResponse response) throws IOException {
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("message", "需要登陆");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.getWriter().print(errorDetails.toString());
}
}

这样通过一个比较复杂的逻辑完成了前后端分离下对接cas单点登录,虽然不太优雅,但能用(