这段时间项目要用到cas sso框架,因为之前的老项目一直是用SpringMVC,cas用起来也没什么问题,但是现在改成了前后端分离,用cas就遇到了一些问题。
正常CAS认证流程:
这里用的是cas4.0,cas客户端是springboot的。遇到的第1个坑第3步的时候有跨域问题,cas里自带的登陆登出页面都是jsp的,用servlet直接跳转。而前后端分离之后就不能直接跳转。前端调接口的时候经过AuthenticationFilter时,发现没有登录会返回给浏览器302的响应以重定向到统一登录页面,这时候浏览器会有跨域问题,即使nginx配了反向代理还是一样。于是就试试让前端直接location.href = redirectUrl进行跳转。当前端访问接口时,在authenticationFilter判断是否登录,如果没有登录的话返回一个值response.getWriter().write("9527");前端获取到这个值后就进行上面的跳转。原来的redirectUrl是CAS登录页面的地址,参数是service=接口地址,跳到这个地址后CAS认证中心会根据cookie里的ticket来判断是否登录,如果登录了,就把service存下来并生成一个ST,但是之后跳转(第7步)的页面也是这个service,总不能让页面显示接口返回的值吧,这是遇到的第2个坑。
所以这里我在redirectUrl里加了一个参数toUrl作为登录成功(判断已经登录)之后跳转的地址,这个由前端来指定,一般都是当前页面。在生成ST的时候获取toUrl并放到flowScope里,然后返给浏览器一个302的响应(第6步),浏览器跳转到service(第7步)的时候将toUrl带过去,然后cas客户端拿到toUrl就直接重定向到该地址。(后来回想觉得这里第6步的时候cas可以直接重定向到toUrl,不用再跳到cas客户端,再重定向到toUrl。)
具体代码修改如下:
GenerateServiceTicketAction部分代码修改:
@Override
protected Event doExecute(final RequestContext context) {
final Service service = WebUtils.getService(context);
final String ticketGrantingTicket = WebUtils.getTicketGrantingTicketId(context);
/* 这里创建一个service用于跳转 */
String toUrl = context.getRequestParameters().get("toUrl");
Service dService = new MyWebApplicationServiceImpl(service.getId(), service.getId(), null, ResponseType.REDIRECT);
context.getFlowScope().put("toService", dService);
context.getFlowScope().put("toUrl", toUrl);
try {
final String serviceTicketId = this.centralAuthenticationService
.grantServiceTicket(ticketGrantingTicket,
service);
WebUtils.putServiceTicketInRequestScope(context,
serviceTicketId);
return success();
} catch (final TicketException e) {
if (isGatewayPresent(context)) {
return result("gateway");
}
}
return error();
}
MyWebApplicationServiceImpl:
// ......省略其他代码......
// 方法改为公有
public MyWebApplicationServiceImpl(final String id,
final String originalUrl, final String artifactId,
final ResponseType responseType) {
super(id, originalUrl, artifactId);
this.responseType = responseType;
}
// ......省略其他代码......
// 添加一个参数toUrl
public Response getResponse(final String ticketId, String toUrl) {
final Map<String, String> parameters = new HashMap<String, String>();
if (StringUtils.hasText(ticketId)) {
parameters.put(CONST_PARAM_TICKET, ticketId);
}
if (toUrl != null && !toUrl.isEmpty() && !toUrl.equals("null")) {
parameters.put("TO_URL", toUrl);
}
if (ResponseType.POST == this.responseType) {
return Response.getPostResponse(getOriginalUrl(), parameters);
}
return Response.getRedirectResponse(getOriginalUrl(), parameters);
}
// ......省略其他代码......
然后修改login-webflow.xml
<action-state id="redirect">
<evaluate expression="flowScope.toService.getResponse(requestScope.serviceTicketId, flowScope.toUrl)" result-type="org.jasig.cas.authentication.principal.Response" result="requestScope.response" />
<transition to="postRedirectDecision" />
</action-state>
这样就将toUrl发送到cas客户端了。客户端那边通过Cas20ProxyReceivingTicketValidationFilter进行ticket验证,在父类里修改doFilter方法:
private String toUrl = null;
public final void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse,
final FilterChain filterChain) throws IOException, ServletException {
if (!preFilter(servletRequest, servletResponse, filterChain)) {
return;
}
final HttpServletRequest request = (HttpServletRequest) servletRequest;
final HttpServletResponse response = (HttpServletResponse) servletResponse;
final String ticket = retrieveTicketFromRequest(request);
toUrl = request.getQueryString() == null ? null
: request.getParameter("TO_URL");
if (CommonUtils.isNotBlank(ticket)) {
logger.debug("Attempting to validate ticket: {}", ticket);
try {
final Assertion assertion = this.ticketValidator.validate(ticket,
constructServiceUrl(request, response));
logger.debug("Successfully authenticated user: {}", assertion.getPrincipal().getName());
request.setAttribute(CONST_CAS_ASSERTION, assertion);
if (this.useSession) {
request.getSession().setAttribute(CONST_CAS_ASSERTION, assertion);
}
onSuccessfulValidation(request, response, assertion);
if (this.redirectAfterValidation) {
logger.debug("Redirecting after successful ticket validation.");
response.sendRedirect(constructServiceUrl(request, response));
return;
}
} catch (final TicketValidationException e) {
logger.debug(e.getMessage(), e);
onFailedValidation(request, response);
if (this.exceptionOnValidationFailure) {
throw new ServletException(e);
}
response.sendError(HttpServletResponse.SC_FORBIDDEN, e.getMessage());
return;
}
} else if(CommonUtils.isNotBlank(toUrl)) {
response.sendRedirect(toUrl);
return;
}
filterChain.doFilter(request, response);
}
这里加了一个toUrl,判断ticket不存在并且toUrl存在,即ST验证通过后就重定向到toUrl(前端页面)。但是这里还需要修改一下验证的url,就是把service里的TO_URL参数去掉,修改AbstractUrlBasedTicketValidator的validate方法:
public final Assertion validate(final String ticket, final String service) throws TicketValidationException {
final String validationUrl = removeToUrl(constructValidationUrl(ticket, service));
logger.debug("Constructing validation url: {}", validationUrl);
try {
logger.debug("Retrieving response from server.");
final String serverResponse = retrieveResponseFromServer(new URL(validationUrl), ticket);
if (serverResponse == null) {
throw new TicketValidationException("The CAS server returned no response.");
}
logger.debug("Server response: {}", serverResponse);
return parseResponseFromServer(serverResponse);
} catch (final MalformedURLException e) {
throw new TicketValidationException(e);
}
}
/**
* 删掉TO_URL参数让后面验证通过
* @param constructValidationUrl
* @return
*/
private String removeToUrl(String constructValidationUrl) {
Matcher m = Pattern.compile("%3FTO_URL.*").matcher(constructValidationUrl);
if(m != null)
return m.replaceAll("");
return constructValidationUrl;
}
修改之后就可以验证成功并跳回前端了,并生成了ST。
所有类的修改都是自己写一个类然后修改代码,再把自己的类注入到Spring容器,父类改了子类也跟着改。
前端调接口的时候设置xhrFields和crossDomain,就可以带上Cookie了:
xhrFields: {
withCredentials: true
},
crossDomain: true
相应的后端响应头也需要设置一下,这里加了个过滤器,注意Access-Control-Allow-Origin不能设为'*':
@Bean
public FilterRegistrationBean<Filter> filterCorsRegistration() {
FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<Filter>();
registration.setFilter(new Filter() {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String origin = request.getHeader("origin");// 获取源站
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Methods", "POST, GET");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Headers",
"Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With");
filterChain.doFilter(servletRequest, servletResponse);
}
});
registration.addUrlPatterns("/*");
registration.setOrder(0);
return registration;
}
弄完之后测试一下,第一次访问前端,前端调接口,判断没有登录就location.href到登录页面,登录之后能返回到前端,之后接口也能调成功了。