0
votes

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.

1

1 Answers

0
votes

Ok so I got it working, it doesn't look like the best, most robust solution so it probably nees some more investigation and care. Nevertheless here it goes:

 @Bean
    public AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> authenticationDetailsSource() {

        return new AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails>() {

            @Override
            public WebAuthenticationDetails buildDetails(
                HttpServletRequest request) {
                return new CustomAuthenticationDetails(request);
            }

        };
    }

    @Bean
    public AuthenticationProvider customAuthenticationProvider() {
        return new AuthenticationProvider() {
            @Override
            public Authentication authenticate(Authentication authentication) throws AuthenticationException {
                String serviceUrl;
                serviceUrl = ((CustomAuthenticationDetails) authentication.getDetails()).getURI();
                if (serviceUrl.equals(env.getRequiredProperty(CAS_URL_CALLBACK_MOBILE))) {
                    return mobileCasAuthenticationProvider().authenticate(authentication);
                } else {
                    return webCasAuthenticationProvider().authenticate(authentication);
                }

            }

            public boolean supports(final Class<?> authentication) {
                return (UsernamePasswordAuthenticationToken.class
                    .isAssignableFrom(authentication))
                    || (CasAuthenticationToken.class.isAssignableFrom(authentication))
                    || (CasAssertionAuthenticationToken.class
                    .isAssignableFrom(authentication));
            }
        };
    }

I added my custom AuthenticationProvider which distinguishes between the two that I really needed. It does it using another custom class, namely CustomAuthenticationDetails which stores information about where the request came from.

public class CustomAuthenticationDetails extends WebAuthenticationDetails {
    private final Logger log = LoggerFactory.getLogger(CustomAuthenticationDetails.class);
    private final String URI;
    private final String sessionId;
    public CustomAuthenticationDetails(HttpServletRequest request) {
        super(request);
        this.URI = request.getRequestURI();
        HttpSession session = request.getSession(false);
        this.sessionId = (session != null) ? session.getId() : null;
    }

    public String getURI() {
        return URI;
    }

    public String getSessionId() {
        return sessionId;
    }
}

And all this is wired together in AuthenticationFilter using authenticationFilter.setAuthenticationDetailsSource(authenticationDetailsSource());. Hope it can help someone in future problems or at least lead in the right direction.