使用具有两个不同 Spring 安全身份验证流程的 CAS 服务器

Using CAS server with two different Spring Security authentication flows

我有一个 spring-boot 后端应用程序,它授权用户使用我们的 JASIG-CAS 服务器并将他们重定向到前端,前端现在可以从后端访问受保护的资源。现在我需要添加一个移动客户端。到目前为止,我的配置 SimpleUrlAuthenticationSuccessHandlerCasAuthenticationFilter 中对我的前端进行了硬编码 url,如下所示:

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

但现在我的移动客户端应该打开浏览器,显示熟悉的 CAS 登录页面,对用户进行身份验证,重定向到后端,这将向移动应用程序发出深度 link。问题是指向前端的硬编码重定向目标。来自 CAS 的请求无论是从前端还是从移动端触发看起来都一样,因为两者都使用浏览器,所以我无法使用我自己的 AuthenticationSuccessHandler 来区分它们。在绝望的情况下,我尝试使用相同的 CAS 服务器但不同的回调端点来构建两个不同的身份验证流程。这是这个怪物: 包裹 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
}

这在某种程度上是有效的。当发出移动身份验证流程时,它会按预期工作,但是当前端发出 /api/login/cas 时,首先使用针对 service=/auth/cas/mobile 的移动过滤器检查来自 CAS 的 TicketGrantingTicket,但发出的 service=/auth/cas 无效TGT 和使用 casWebAuthenticationFilter 的后续验证使用当然是无效的票证。

现在我不知道如何强制 CasAuthenticationFilter 只处理某些票证?也许我的想法很纠结,看不到更简单的解决方案?也许我应该做两个单独的 http 安全配置?

编辑: 似乎这一切都归结为我放置 AuthenticationProvider:

的顺序
@Inject
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .userDetailsService(userDetailsService)
            .passwordEncoder(passwordEncoder())
            .and()
            .authenticationProvider(webCasAuthenticationProvider())
            .authenticationProvider(mobileCasAuthenticationProvider());
    }

mobileAuthenticationProider() 先行时,移动登录有效,而网络登录无效。当我切换它们的调用顺序时,移动身份验证失败,网络登录开始工作。

好的,所以我让它工作了,它看起来不像是最好的、最可靠的解决方案,所以它可能需要更多的调查和关注。尽管如此:

 @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));
            }
        };
    }

我添加了我的自定义 AuthenticationProvider,它区分了我真正需要的两者。它使用另一个自定义 class,即 CustomAuthenticationDetails,它存储有关请求来自何处的信息。

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;
    }
}

所有这些都使用 authenticationFilter.setAuthenticationDetailsSource(authenticationDetailsSource());AuthenticationFilter 中连接在一起。希望它可以帮助某人解决未来的问题,或者至少引导正确的方向。