I have a spring-boot backend application that authorizes users using our JASIG-CAS server and redirects them to frontend which can now access protected resources from backend. Now I need to add a mobile client. Up until now my configuration had SimpleUrlAuthenticationSuccessHandler with hardcoded url of my frontend in CasAuthenticationFilter like so:
public CasAuthenticationFilter casAuthenticationFilter() throws Exception {
CasAuthenticationFilter authenticationFilter = new CasAuthenticationFilter(); authenticationFilter.setAuthenticationManager(authenticationManager());
authenticationFilter.setServiceProperties(serviceProperties());
authenticationFilter.setFilterProcessesUrl("/auth/cas");
SimpleUrlAuthenticationSuccessHandler simpleUrlAuthenticationSuccessHandler =
new SimpleUrlAuthenticationSuccessHandler(env.getRequiredProperty(CAS_REDIRECT_TARGET));
authenticationFilter.setAuthenticationSuccessHandler(simpleUrlAuthenticationSuccessHandler);
return authenticationFilter;
}//CasAuthenticationFilter
But now my mobile client should open browser, show familiar CAS login page, authenticate user, redirect to backend which will issue a deep-link to mobile application. The problem is the hardcoded redirection target which points to frontend. The request from CAS looks the same regardles if it was triggerd from frontend or mobile because both use browsers, so I can't distinguish them using my own AuthenticationSuccessHandler. In a desperate act I tried constructing two different authentication flows using using the same CAS server but different callback endpoints. Here is this monster:
package com.my.company.config;
import org.jasig.cas.client.session.SingleSignOutFilter;
import org.jasig.cas.client.validation.Cas20ServiceTicketValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpMethod;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.cas.ServiceProperties;
import org.springframework.security.cas.authentication.CasAssertionAuthenticationToken;
import org.springframework.security.cas.authentication.CasAuthenticationProvider;
import org.springframework.security.cas.authentication.NullStatelessTicketCache;
import org.springframework.security.cas.web.CasAuthenticationEntryPoint;
import org.springframework.security.cas.web.CasAuthenticationFilter;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.userdetails.AuthenticationUserDetailsService;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.inject.Inject;
import javax.servlet.*;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final Logger log = LoggerFactory.getLogger(SecurityConfiguration.class);
private static final String CAS_URL_SERVER = "cas.url.server";
private static final String CAS_URL_LOGIN = "cas.url.login";
private static final String CAS_URL_LOGOUT = "cas.url.logout";
private static final String CAS_URL_SERVICE = "cas.url.service";
private static final String CAS_URL_CALLBACK = "cas.url.callback";
private static final String CAS_REDIRECT_TARGET = "cas.redirect.target";
@Inject
private Environment env;
@Inject
private AjaxAuthenticationSuccessHandler ajaxAuthenticationSuccessHandler;
@Inject
private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;
@Inject
@Qualifier("casUserDetailsService")
private AuthenticationUserDetailsService<CasAssertionAuthenticationToken> casAuthenticationUserDetailsService;
@Inject
@Qualifier("formUserDetailsService")
private UserDetailsService userDetailsService;
@Inject
private Http401UnauthorizedEntryPoint authenticationEntryPoint;
@Bean
public Cas20ServiceTicketValidator cas20ServiceTicketValidator() {
return new Cas20ServiceTicketValidator(env.getRequiredProperty(CAS_URL_SERVER));
}
@Bean(name="webAuthProvider")
public CasAuthenticationProvider webCasAuthenticationProvider() {
CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider();
casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator());
casAuthenticationProvider.setStatelessTicketCache(new NullStatelessTicketCache());
casAuthenticationProvider.setKey("CAS_WEB_AUTHENTICATION_PROVIDER");
casAuthenticationProvider.setAuthenticationUserDetailsService(casAuthenticationUserDetailsService);
casAuthenticationProvider.setMessageSource(new SpringSecurityMessageSource());
casAuthenticationProvider.setServiceProperties(webServiceProperties());
return casAuthenticationProvider;
}//CasAuthenticationProvider
@Bean(name="mobileAuthProvider")
public CasAuthenticationProvider mobileCasAuthenticationProvider() {
CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider();
casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator());
casAuthenticationProvider.setStatelessTicketCache(new NullStatelessTicketCache());
casAuthenticationProvider.setKey("CAS_MOBILE_AUTHENTICATION_PROVIDER");
casAuthenticationProvider.setAuthenticationUserDetailsService(casAuthenticationUserDetailsService);
casAuthenticationProvider.setMessageSource(new SpringSecurityMessageSource());
casAuthenticationProvider.setServiceProperties(mobileServiceProperties());
return casAuthenticationProvider;
}//CasAuthenticationProvider
@Bean
public SingleSignOutFilter singleSignOutFilter() {
SingleSignOutFilter filter = new SingleSignOutFilter();
return filter;
}//SingleSignOutFilter
@Inject
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder())
.and()
.authenticationProvider(mobileCasAuthenticationProvider())
.authenticationProvider(webCasAuthenticationProvider());
}
@Bean(name = "webCasFilter")
public CasAuthenticationFilter webCasAuthenticationFilter() throws Exception {
CasAuthenticationFilter authenticationFilter = new CasAuthenticationFilter();
authenticationFilter.setBeanName("webCasFilter");
authenticationFilter.setAuthenticationManager(authenticationManager());
authenticationFilter.setServiceProperties(webServiceProperties());
authenticationFilter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/auth/cas"));
//authenticationFilter.setFilterProcessesUrl("/auth/cas");
SimpleUrlAuthenticationSuccessHandler simpleUrlAuthenticationSuccessHandler =
new SimpleUrlAuthenticationSuccessHandler(env.getRequiredProperty(CAS_REDIRECT_TARGET));
authenticationFilter.setAuthenticationSuccessHandler(simpleUrlAuthenticationSuccessHandler);
return authenticationFilter;
}//CasAuthenticationFilter
@Bean(name = "mobileCasFilter")
public CasAuthenticationFilter mobileCasAuthenticationFilter() throws Exception {
CasAuthenticationFilter authenticationFilter = new CasAuthenticationFilter();
authenticationFilter.setBeanName("mobileCasFilter");
authenticationFilter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/auth/cas/mobile"));
authenticationFilter.setAuthenticationManager(authenticationManager());
authenticationFilter.setServiceProperties(mobileServiceProperties());
//authenticationFilter.setFilterProcessesUrl("/auth/cas/mobile");
SimpleUrlAuthenticationSuccessHandler simpleUrlAuthenticationSuccessHandler =
new SimpleUrlAuthenticationSuccessHandler("/mobile/deep-link");
authenticationFilter.setAuthenticationSuccessHandler(simpleUrlAuthenticationSuccessHandler);
return authenticationFilter;
}//CasAuthenticationFilter
@Bean(name="webCasAuthenticationEntryPoint")
public CasAuthenticationEntryPoint webCasAuthenticationEntryPoint() {
CasAuthenticationEntryPoint entryPoint = new CasAuthenticationEntryPoint();
entryPoint.setLoginUrl(env.getRequiredProperty(CAS_URL_LOGIN));
entryPoint.setServiceProperties(webServiceProperties());
return entryPoint;
}//CasAuthenticationEntryPoint
@Bean(name="mobileCasAuthenticationEntryPoint")
public CasAuthenticationEntryPoint mobileCasAuthenticationEntryPoint() {
CasAuthenticationEntryPoint entryPoint = new CasAuthenticationEntryPoint();
entryPoint.setLoginUrl(env.getRequiredProperty(CAS_URL_LOGIN));
entryPoint.setServiceProperties(mobileServiceProperties());
return entryPoint;
}//CasAuthenticationEntryPoint
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/scripts/**/*.{js,html}")
.antMatchers("/bower_components/**")
.antMatchers("/i18n/**")
.antMatchers("/assets/**")
.antMatchers("/swagger-ui/**")
.antMatchers("/test/**")
.antMatchers("/console/**");
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
private class CasRedirectionFilter implements Filter {
public void init(FilterConfig fConfig) throws ServletException {
}
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException {
HttpServletResponse res = (HttpServletResponse) response;
//CasAuthenticationEntryPoint caep = casAuthenticationEntryPoint();
res.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
HttpServletRequest req = (HttpServletRequest) request;;
String contextPath = req.getRequestURI();
if(contextPath.equals("/api/login/mobile")){
String redirectUrl = "https://cas.server.com/cas/login?service=http://localhost:8080/auth/cas/mobile";
res.setHeader("Location", redirectUrl);
}else {
String redirectUrl = "https://cas.server.com/cas/login?service=http://localhost:8080/auth/cas";
res.setHeader("Location", redirectUrl);
}
}
public void destroy() {
}
}
@Bean
public FilterChainProxy loginFilter() throws Exception {
List<SecurityFilterChain> chains = new ArrayList<SecurityFilterChain>();
chains.add(new DefaultSecurityFilterChain(new AntPathRequestMatcher("/api/login/cas"), new CasRedirectionFilter()));
chains.add(new DefaultSecurityFilterChain(new AntPathRequestMatcher("/api/login/mobile"), new CasRedirectionFilter()));
log.debug("loginFilter {}", chains);
return new FilterChainProxy(chains);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
.and()
.csrf()
.disable()
.addFilterBefore(mobileCasAuthenticationFilter(),CasAuthenticationFilter.class)
.addFilterBefore(webCasAuthenticationFilter(),CasAuthenticationFilter.class)
.addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class)
.addFilter(loginFilter())
.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.and()
.logout()
.logoutUrl("/api/logout")
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.logoutSuccessUrl(env.getRequiredProperty(CAS_URL_LOGOUT))
.permitAll()
.and()
.headers()
.frameOptions()
.disable()
.and()
.formLogin()
//.defaultSuccessUrl(env.getRequiredProperty(CAS_REDIRECT_TARGET), true)
.successHandler(ajaxAuthenticationSuccessHandler)
.failureHandler(ajaxAuthenticationFailureHandler)
.loginProcessingUrl("/api/authentication")
.usernameParameter("j_username")
.passwordParameter("j_password")
.permitAll()
.and()
.authorizeRequests()
.antMatchers(org.springframework.http.HttpMethod.OPTIONS, "/api/**").permitAll()
.antMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll()
.antMatchers("/app/**").authenticated()
.antMatchers(HttpMethod.GET, "/api/login").authenticated()
.antMatchers("/api/login/mobile").authenticated()
.antMatchers("/api/login/cas").authenticated()
.antMatchers("/api/register").permitAll()
.antMatchers("/api/activate").permitAll()
.antMatchers("/api/authenticate").authenticated()
.antMatchers("/api/logs/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/api/**").authenticated()
.antMatchers("/metrics/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/mobile/**").permitAll()
.antMatchers("/health/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/trace/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/dump/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/shutdown/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/beans/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/configprops/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/info/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/autoconfig/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/env/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/trace/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/api-docs/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/protected/**").authenticated();
}
@EnableGlobalMethodSecurity(prePostEnabled = true, jsr250Enabled = true)
private static class GlobalSecurityConfiguration extends GlobalMethodSecurityConfiguration {
@Inject
ConferenceRepository conferenceRepository;
@Inject
UserRepository userRepository;
public GlobalSecurityConfiguration() {
super();
}
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
PermissionChecker permissionEvaluator = new PermissionChecker(conferenceRepository, userRepository);
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(permissionEvaluator);
return expressionHandler;
}
}
@Bean(name="webServiceProperties")
public ServiceProperties webServiceProperties() {
ServiceProperties serviceProperties = new ServiceProperties();
serviceProperties.setService("http://localhost:8080/auth/cas");
serviceProperties.setSendRenew(true);
serviceProperties.setAuthenticateAllArtifacts(true);
return serviceProperties;
}//serviceProperties
@Bean(name="mobileServiceProperties")
public ServiceProperties mobileServiceProperties() {
ServiceProperties serviceProperties = new ServiceProperties();
serviceProperties.setService("http://localhost:8080/auth/cas/mobile");
serviceProperties.setSendRenew(true);
serviceProperties.setAuthenticateAllArtifacts(true);
return serviceProperties;
}//serviceProperties
}
This works to some degree. When mobile authentication flow is issued it works as intended but when frontend issues /api/login/cas the TicketGrantingTicket from CAS is first checked using mobile filter against service=/auth/cas/mobile but was issued for service=/auth/cas which invalidates TGT and subsequent validation using casWebAuthenticationFilter uses that invalidated ticket which of course.
So now I'm out of ideas how to force CasAuthenticationFilter to process only certain tickets? Perhaps I'm so tangled up in my idea that I can't see simpler solution? Maybe I should do two separate http security configs?
EDIT:
It seems that it all boils down to the order in which I put AuthenticationProvider:
@Inject
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder())
.and()
.authenticationProvider(webCasAuthenticationProvider())
.authenticationProvider(mobileCasAuthenticationProvider());
}
When mobileAuthenticationProider() goes first then the mobile login works and web one doesn't when I switch the order in which they are called then mobile authentication fails and the web one starts to work.