Search code examples
spring-securitygoogle-apigoogle-oauthspring-security-oauth2

Spring Security OAuth2 (google) web app in redirect loop


I am trying to build a Spring MVC application and securing it with Spring Security OAuth2 and the provider is Google. I was able to get the web app working without security and with form login. However, I am not able to get OAuth with google to work. Google app setup is fine as I can get the call backs etc to work with a non Spring Security app.

My security config is as follows:

<?xml version="1.0" encoding="UTF-8"?>
<b:beans xmlns:sec="http://www.springframework.org/schema/security"
         xmlns:b="http://www.springframework.org/schema/beans"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
                        http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd">
    <sec:http use-expressions="true" entry-point-ref="clientAuthenticationEntryPoint">
        <sec:http-basic/>
        <sec:logout/>
        <sec:anonymous enabled="false"/>

        <sec:intercept-url pattern="/**" access="isFullyAuthenticated()"/>

        <sec:custom-filter ref="oauth2ClientContextFilter" after="EXCEPTION_TRANSLATION_FILTER"/>
        <sec:custom-filter ref="googleAuthenticationFilter" before="FILTER_SECURITY_INTERCEPTOR"/>
    </sec:http>

    <b:bean id="clientAuthenticationEntryPoint" class="org.springframework.security.oauth2.provider.error.OAuth2AuthenticationEntryPoint"/>

    <sec:authentication-manager alias="alternateAuthenticationManager">
        <sec:authentication-provider>
            <sec:user-service>
                <sec:user name="user" password="password" authorities="DOMAIN_USER"/>
            </sec:user-service>
        </sec:authentication-provider>
    </sec:authentication-manager>
</b:beans>

The OAuth2 protected resource is as follows:

@Configuration
@EnableOAuth2Client
class ResourceConfiguration {
    @Autowired
    private Environment env;

    @Resource
    @Qualifier("accessTokenRequest")
    private AccessTokenRequest accessTokenRequest;

    @Bean
    public OAuth2ProtectedResourceDetails googleResource() {
        AuthorizationCodeResourceDetails details = new AuthorizationCodeResourceDetails();
        details.setId("google-app");
        details.setClientId(env.getProperty("google.client.id"));
        details.setClientSecret(env.getProperty("google.client.secret"));
        details.setAccessTokenUri(env.getProperty("google.accessTokenUri"));
        details.setUserAuthorizationUri(env.getProperty("google.userAuthorizationUri"));
        details.setTokenName(env.getProperty("google.authorization.code"));
        String commaSeparatedScopes = env.getProperty("google.auth.scope");
        details.setScope(parseScopes(commaSeparatedScopes));
        details.setPreEstablishedRedirectUri(env.getProperty("google.preestablished.redirect.url"));
        details.setUseCurrentUri(false);
        details.setAuthenticationScheme(AuthenticationScheme.query);
        details.setClientAuthenticationScheme(AuthenticationScheme.form);
        return details;
    }

    private List<String> parseScopes(String commaSeparatedScopes) {
        List<String> scopes = newArrayList();
        Collections.addAll(scopes, commaSeparatedScopes.split(","));
        return scopes;
    }

    @Bean
    public OAuth2RestTemplate googleRestTemplate() {
        return new OAuth2RestTemplate(googleResource(), new DefaultOAuth2ClientContext(accessTokenRequest));
    }

    @Bean
    public AbstractAuthenticationProcessingFilter googleAuthenticationFilter() {
        return new GoogleOAuthentication2Filter(new GoogleAppsDomainAuthenticationManager(), googleRestTemplate(), "https://accounts.google.com/o/oauth2/auth", "http://localhost:9000");
    }
}

The custom authentication filter which I have written to throw a Redirect exception to get the OAuth2 authorization is as follows:

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        try {
            logger.info("OAuth2 Filter Triggered!! for path {} {}", request.getRequestURI(), request.getRequestURL().toString());
            logger.info("OAuth2 Filter hashCode {} request hashCode {}", this.hashCode(), request.hashCode());
            String code = request.getParameter("code");
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            logger.info("Code is {} and authentication is {}", code, authentication == null ? null : authentication.isAuthenticated());
            // not authenticated
            if (requiresRedirectForAuthentication(code)) {
                URI authURI = new URI(googleAuthorizationUrl);

                logger.info("Posting to {} to trigger auth redirect", authURI);
                String url = "https://www.googleapis.com/oauth2/v2/userinfo?access_token=" + oauth2RestTemplate.getAccessToken();
                logger.info("Getting profile data from {}", url);
                // Should throw RedirectRequiredException
                oauth2RestTemplate.getForEntity(url, GoogleProfile.class);

                // authentication in progress
                return null;
            } else {
                logger.info("OAuth callback received");
                // get user profile and prepare the authentication token object.

                String url = "https://www.googleapis.com/oauth2/v2/userinfo?access_token=" + oauth2RestTemplate.getAccessToken();
                logger.info("Getting profile data from {}", url);
                ResponseEntity<GoogleProfile> forEntity = oauth2RestTemplate.getForEntity(url, GoogleProfile.class);
                GoogleProfile profile = forEntity.getBody();

                CustomOAuth2AuthenticationToken authenticationToken = getOAuth2Token(profile.getEmail());
                authenticationToken.setAuthenticated(false);
                Authentication authenticate = getAuthenticationManager().authenticate(authenticationToken);
                logger.info("Final authentication is {}", authenticate == null ? null : authenticate.isAuthenticated());

                return authenticate;
            }
        } catch (URISyntaxException e) {
            Throwables.propagate(e);
        }
        return null;
    }

The filter chain sequence from the Spring web app is as follows:

o.s.b.c.e.ServletRegistrationBean - Mapping servlet: 'dispatcherServlet' to [/] 
o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'metricFilter' to: [/*] 
o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'oauth2ClientContextFilter' to: [/*] 
o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'googleOAuthFilter' to: [/*] 
o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'org.springframework.security.filterChainProxy' to: [/*] 
o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'org.springframework.security.web.access.intercept.FilterSecurityInterceptor#0' to: [/*] 
o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'hiddenHttpMethodFilter' to: [/*] 
o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'applicationContextIdFilter' to: [/*] 
o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'webRequestLoggingFilter' to: [/*] 

The redirect to Google works fine and I get the callback to the filter and the authentication is successful. However after that, the request results in a redirect and it invokes the filter again (the request is the same, I have checked the hasCode). On the second call the authentication in the SecurityContext is null. As part of the first authentication call the Authentication object was populated in the security context, so why does it disappear? I am working with Spring Security for the first time so may have made newbie mistake.


Solution

  • After playing around with Spring Security configuration and the filters I was finally able to get this working. I had to make couple of important changes

    • I used a standard Spring OAuth2 filter (org.springframework.security.oauth2.client.filter.OAuth2ClientAuthenticationProcessingFilter) instead of the custom filter I was using.
    • Change the intercept URL of the authentication filter to be /googleLogin and added an authentication entry point which redirects to this URL on authentication failure.

    Overall the flow is as follows

    • Browser accesses / and the request passes through the OAuth2ClientContextFilter and OAuth2ClientAuthenticationProcessingFilter as the context does not match. The configured context path for login is /googleLogin
    • The security interceptor FilterSecurityInterceptor detects that the the user is anonymous and throws an access denied exception.
    • Spring security's ExceptionTranslationFilter catches the access denied exception and asks the configured authentication entry point to handle it which issues a redirect to /googleLogin.
    • For the request /googleLogin, the filter OAuth2AuthenticationProcessingFilter tries to access the Google protected resource and an UserRedirectRequiredException is thrown which is translated into a HTTP redirect to Google (with the OAuth2 details) by OAuth2ClientContextFilter.
    • On successful authentication from Google the browser is redirected back to /googleLogin with the OAuth code. The filter OAuth2AuthenticationProcessingFilter handles this and creates an Authentication object and updates the SecurityContext.
    • At this point the user is fully authenticated and redirect to / is issued by the OAuth2AuthenticationProcessingFilter.
    • FilterSecurityInterceptor allows the request to proceed as the SecurityContext contains an Authentication object which is authenticated.
    • Finally the application page which is secured using an expression like isFullyAuthenticated() or similar is rendered.

    The security context xml is as follows:

    <sec:http use-expressions="true" entry-point-ref="clientAuthenticationEntryPoint">
        <sec:http-basic/>
        <sec:logout/>
        <sec:anonymous enabled="false"/>
    
        <sec:intercept-url pattern="/**" access="isFullyAuthenticated()"/>
    
        <!-- This is the crucial part and the wiring is very important -->
        <!-- 
            The order in which these filters execute are very important. oauth2ClientContextFilter must be invoked before 
            oAuth2AuthenticationProcessingFilter, that's because when a redirect to Google is required, oAuth2AuthenticationProcessingFilter 
            throws a UserRedirectException which the oauth2ClientContextFilter handles and generates a redirect request to Google.
            Subsequently the response from Google is handled by the oAuth2AuthenticationProcessingFilter to populate the 
            Authentication object and stored in the SecurityContext
        -->
        <sec:custom-filter ref="oauth2ClientContextFilter" after="EXCEPTION_TRANSLATION_FILTER"/>
        <sec:custom-filter ref="oAuth2AuthenticationProcessingFilter" before="FILTER_SECURITY_INTERCEPTOR"/>
    </sec:http>
    
    <b:bean id="oAuth2AuthenticationProcessingFilter" class="org.springframework.security.oauth2.client.filter.OAuth2ClientAuthenticationProcessingFilter">
        <b:constructor-arg name="defaultFilterProcessesUrl" value="/googleLogin"/>
        <b:property name="restTemplate" ref="googleRestTemplate"/>
        <b:property name="tokenServices" ref="tokenServices"/>
    </b:bean>
    
    <!--
        These token classes are mostly a clone of the Spring classes but have the structure modified so that the response
        from Google can be handled.
    -->
    <b:bean id="tokenServices" class="com.rst.oauth2.google.security.GoogleTokenServices">
        <b:property name="checkTokenEndpointUrl" value="https://www.googleapis.com/oauth2/v1/tokeninfo"/>
        <b:property name="clientId" value="${google.client.id}"/>
        <b:property name="clientSecret" value="${google.client.secret}"/>
        <b:property name="accessTokenConverter">
            <b:bean class="com.rst.oauth2.google.security.GoogleAccessTokenConverter">
                <b:property name="userTokenConverter">
                    <b:bean class="com.rst.oauth2.google.security.DefaultUserAuthenticationConverter"/>
                </b:property>
            </b:bean>
        </b:property>
    </b:bean>
    
    <!-- 
        This authentication entry point is used for all the unauthenticated or unauthorised sessions to be directed to the 
        /googleLogin URL which is then intercepted by the oAuth2AuthenticationProcessingFilter to trigger authentication from 
        Google.
    -->
    <b:bean id="clientAuthenticationEntryPoint" class="org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint">
        <b:property name="loginFormUrl" value="/googleLogin"/>
    </b:bean>
    

    Also the Java Config for the OAuth2 resources is as follows:

    @Configuration
    @EnableOAuth2Client
    class OAuth2SecurityConfiguration {
        @Autowired
        private Environment env;
    
        @Resource
        @Qualifier("accessTokenRequest")
        private AccessTokenRequest accessTokenRequest;
    
        @Bean
        @Scope("session")
        public OAuth2ProtectedResourceDetails googleResource() {
            AuthorizationCodeResourceDetails details = new AuthorizationCodeResourceDetails();
            details.setId("google-oauth-client");
            details.setClientId(env.getProperty("google.client.id"));
            details.setClientSecret(env.getProperty("google.client.secret"));
            details.setAccessTokenUri(env.getProperty("google.accessTokenUri"));
            details.setUserAuthorizationUri(env.getProperty("google.userAuthorizationUri"));
            details.setTokenName(env.getProperty("google.authorization.code"));
            String commaSeparatedScopes = env.getProperty("google.auth.scope");
            details.setScope(parseScopes(commaSeparatedScopes));
            details.setPreEstablishedRedirectUri(env.getProperty("google.preestablished.redirect.url"));
            details.setUseCurrentUri(false);
            details.setAuthenticationScheme(AuthenticationScheme.query);
            details.setClientAuthenticationScheme(AuthenticationScheme.form);
            return details;
        }
    
        private List<String> parseScopes(String commaSeparatedScopes) {
            List<String> scopes = newArrayList();
            Collections.addAll(scopes, commaSeparatedScopes.split(","));
            return scopes;
        }
    
        @Bean
        @Scope(value = "session", proxyMode = ScopedProxyMode.INTERFACES)
        public OAuth2RestTemplate googleRestTemplate() {
            return new OAuth2RestTemplate(googleResource(), new DefaultOAuth2ClientContext(accessTokenRequest));
        }
    }
    

    I had to override some of the Spring classes as the format of the token from Google and the one expected by Spring don't match. So there is some custom handiwork required there.