Search code examples
reactjsspring-bootoauthfacebook-access-token

How to authenticate users with Social Login (Facebook, Google) in a REST API backend with Spring Boot (separate React Frontend)


I am currently working on a Spring Boot REST API. I have successfully added login using client credentials with Spring Oauth and Spring Security (I can successfully get access token and refresh token using /oauth/token endpoint). But now I want to provide social login with Facebook and Google. As I understand, this is the flow.

  1. User clicks Login with Social button in React frontend.
  2. Then, he will be asked to grant access. (Still in React)
  3. After that he will be redirected to the react front end with an access token.
  4. Frontend sends that access token to the Spring Boot backend. (I don't know to what endpoint)
  5. Then backend uses that access token to fetch details from the Facebook/Google and check whether a such user exists in our database.
  6. If such user exists, backend will return access and refresh tokens to the frontend.
  7. Now frontend can consume all the endpoints.

My problem is, I have no idea about the steps 4,5 and 6. Do I have to make a custom endpoint to receive FB/Google access tokens? How do I issue custom access and refresh tokens in Spring Boot?

I would really appreciate it if you could help me with this scenario.


Solution

  • The flow it's the following:

    1. Front-End calls spring to /oauth2/authorization/facebook(or whatever client do you wanna use)
    2. Back-end respond with a redirect to Facebook login page(including in the query params, client_id, scope, redirect_uri(must be the same present on your developer console) and state which is used to avoid XSRF attacks, according to OAuth2 Standards)

    you can see more details here https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1

    state RECOMMENDED. An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client. The parameter SHOULD be used for preventing cross-site request forgery as described in Section 10.12. 3) Once the user log-in and accept whatever popup facebook or other services will show, the user will be redirected to the page present in "redirect_uri", this page should be a component of your ReactJs. The callback will come with some datas put in the query params, usually those params are two, state(it's the same you sent to facebook) and code(which is used from the BE to end the login flow).

    1. Once facebook or whatever service, called you back, you have to take those 2 params, from the url(using JS for instance) and call the /login/oauth2/code/facebook/?code=CODE_GENERATED_BY_FACEBOOK&?state=STATE_GENERATED_BY_SPRING

    2. Spring will call the facebook service(with an implementation of OAuth2AccessTokenResponseClient, using your secret_token, client_id, code and few other fields. Once facebook responds with the access_token and refresh_token, spring call an implementation of OAuth2UserService, used to get user info from facebook using the access_token created a moment before, at facebook's response a session will be created including the principal. (You can intercept the login success creating an implementation of SimpleUrlAuthenticationSuccessHandlerand adding it to your spring security configuration. (For facebook, google and otka in theory OAuth2AccessTokenResponseClient and OAuth2UserService implementations should already exist.

    In that handler you can put the logic to add and look for an existing user.

    coming back to the default behavior

    1. Once spring created the new session and gave you the JSESSIONID cookie, it will redirect you to the root (I believe, I don't remember exactly which is the default path after the login, but you can change it, creating your own implementation of the handler I told you before)

    Note: access_token and refresh_token will be stored in a OAuth2AuthorizedClient, stored in the ClientRegistrationRepository.

    This is the end. From now then you can call your back end with that cookie and the be will see you as a logged user. My suggestion is once you got the simple flow working, you should implement a JWT token to use and store in the localstorage of your browser instead of using the cookie.

    Hopefully I gave you the infos you were looking for, if I missed something, misunderstood something or something it's not clear let me know in the comment.

    UPDATE (some java samples)

    My OAuth2 SecurityConfig :

    NOTE:

    PROTECTED_URLS it's just : public static final RequestMatcher PROTECTED_URLS = new NegatedRequestMatcher(PUBLIC_URLS);

    PUBLIC_URLS it's just: private static final RequestMatcher PUBLIC_URLS = new OrRequestMatcher( new AntPathRequestMatcher("/api/v1/login"));

    Also note I'm using a dual HttpSecurity configuration. (But in this case it's useless to public that too)

    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class OAuth2ClientSecurityConfiguration extends WebSecurityConfigurerAdapter {
        private final JWTService jwtService;
        private final TempUserDataService tempUserDataService;
        private final OAuth2AuthorizedClientRepo authorizedClientRepo;
        private final OAuth2AuthorizedClientService clientService;
        private final UserAuthenticationService authenticationService;
        private final SimpleUrlAuthenticationSuccessHandler successHandler; //This is the default one, this bean has been created in another HttpSecurity Configuration file.
        private final OAuth2TokenAuthenticationProvider authenticationProvider2;
        private final CustomOAuth2AuthorizedClientServiceImpl customOAuth2AuthorizedClientService;
        private final TwitchOAuth2UrlAuthSuccessHandler oauth2Filter; //This is the success handler customized.
    
        //In this bean i set the default successHandler and the current AuthManager.
        @Bean("oauth2TokenAuthenticaitonFilter")
        TokenAuthenticationFilter oatuh2TokenAuthenticationFilter() throws Exception {
            TokenAuthenticationFilter filter = new TokenAuthenticationFilter(PROTECTED_URLS);
            filter.setAuthenticationSuccessHandler(successHandler);
            filter.setAuthenticationManager(authenticationManager());
            return filter;
        }
    
        @PostConstruct
        public void setFilterSettings() {
            oauth2Filter.setRedirectStrategy(new NoRedirectStrategy());
        }
    
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.authenticationProvider(authenticationProvider2);
        }
    
    
        @Bean
        public RestOperations restOperations() {
            return new RestTemplate();
        }
    
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and().authorizeRequests().antMatchers("/twitch/**").authenticated()
                    .and().csrf().disable()
                    .formLogin().disable()
                    .httpBasic().disable()
                    .logout().disable().authenticationProvider(authenticationProvider2)
                    .exceptionHandling()
                    .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
                    .and()
                    .addFilterBefore(oatuh2TokenAuthenticationFilter(), AnonymousAuthenticationFilter.class)
                    .oauth2Login().successHandler(oauth2Filter).tokenEndpoint()
                    .accessTokenResponseClient(new RestOAuth2AccessTokenResponseClient(restOperations()))
                    .and().authorizedClientService(customOAuth2AuthorizedClientService)
                    .userInfoEndpoint().userService(new RestOAuth2UserService(restOperations(), tempUserDataService, authorizedClientRepo));
        }
    
        @Bean
        FilterRegistrationBean disableAutoRegistrationOAuth2Filter() throws Exception {
            FilterRegistrationBean registration = new FilterRegistrationBean(oatuh2TokenAuthenticationFilter());
            registration.setEnabled(false);
            return registration;
        }
    }
    

    By the fact that my SessionCreationPolicy.STATELESS the cookie created by spring after the end of the OAuth2 Flow is useless. So once the process its over I give to the user a TemporaryJWT Token used to access the only possible service (the register service) My TokenAutheticationFilter:

    public class TokenAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
        private static final String AUTHORIZATION = "Authorization";
        private static final String BEARER = "Bearer";
    
        public TokenAuthenticationFilter(RequestMatcher requiresAuthenticationRequestMatcher) {
            super(requiresAuthenticationRequestMatcher);
        }
    
        @Override
        public Authentication attemptAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws AuthenticationException, IOException, ServletException {
            String token = Optional.ofNullable(httpServletRequest.getHeader(AUTHORIZATION))
                    .map(v -> v.replace(BEARER, "").trim())
                    .orElseThrow(() -> new BadCredentialsException("Missing authentication token."));
            Authentication auth = new UsernamePasswordAuthenticationToken(token, token);
            return getAuthenticationManager().authenticate(auth);
        }
    
    
        @Override
        protected void successfulAuthentication(
                HttpServletRequest request,
                HttpServletResponse response,
                FilterChain chain,
                Authentication authResult) throws IOException, ServletException {
            super.successfulAuthentication(request, response, chain, authResult);
            chain.doFilter(request, response);
        }
    
        @Override
        protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
           response.setStatus(401);
        }
    }
    

    TwitchOAuth2UrlAuthSuccessHandler (This is where all the magic happens):

    This handler is called once the userService and the userService is called when the user calls api.myweb.com/login/oauth2/code/facebook/?code=XXX&state=XXX. (please don't forget the state)

    @Component
    @RequiredArgsConstructor
    public class TwitchOAuth2UrlAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
        private final OAuth2AuthorizedClientRepo oAuth2AuthorizedClientRepo;
        private final UserAuthenticationService authenticationService;
        private final JWTService jwtService;
        private final Gson gson;
    
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
            super.onAuthenticationSuccess(request, response, authentication);
            response.setStatus(200);
            Cookie cookie = new Cookie("JSESSIONID", null);
            cookie.setHttpOnly(true);
            cookie.setSecure(true);
            cookie.setPath("/");
            cookie.setMaxAge(0);
            response.addCookie(cookie);
            Optional<OAuth2AuthorizedClientEntity> oAuth2AuthorizedClient = oAuth2AuthorizedClientRepo.findById(new OAuth2AuthorizedClientId(((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId(), authentication.getName()));
            if (oAuth2AuthorizedClient.isPresent() && oAuth2AuthorizedClient.get().getUserDetails() != null) {
                response.getWriter().write(gson.toJson(authenticationService.loginWithCryptedPassword(oAuth2AuthorizedClient.get().getUserDetails().getUsername(), oAuth2AuthorizedClient.get().getUserDetails().getPassword())));
                response.setContentType("application/json");
                response.setCharacterEncoding("UTF-8");
                response.getWriter().flush();
            } else {
                response.setHeader("Authorization", jwtService.createTempToken(((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId(), authentication.getName()));
            }
        }
    
        @Override
        protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response) {
            return "";
        }
    
    
    }
    

    RestOAuth2AccessTokenResponseClient (its responsable to take Access_token and refresh_token from FB)

    public class RestOAuth2AccessTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
        private final RestOperations restOperations;
    
        public RestOAuth2AccessTokenResponseClient(RestOperations restOperations) {
            this.restOperations = restOperations;
        }
    
        @Override
        public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest) {
            ClientRegistration clientRegistration = authorizationGrantRequest.getClientRegistration();
            String tokenUri = clientRegistration.getProviderDetails().getTokenUri();
            MultiValueMap<String, String> tokenRequest = new LinkedMultiValueMap<>();
            tokenRequest.add("client_id", clientRegistration.getClientId());
            tokenRequest.add("client_secret", clientRegistration.getClientSecret());
            tokenRequest.add("grant_type", clientRegistration.getAuthorizationGrantType().getValue());
            tokenRequest.add("code", authorizationGrantRequest.getAuthorizationExchange().getAuthorizationResponse().getCode());
            tokenRequest.add("redirect_uri", authorizationGrantRequest.getAuthorizationExchange().getAuthorizationRequest().getRedirectUri());
            tokenRequest.add("scope", String.join(" ", authorizationGrantRequest.getClientRegistration().getScopes()));
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
            headers.add(HttpHeaders.USER_AGENT, "Discord Bot 1.0");
            ResponseEntity<AccessResponse> responseEntity = restOperations.exchange(tokenUri, HttpMethod.POST, new HttpEntity<>(tokenRequest, headers), AccessResponse.class);
            if (!responseEntity.getStatusCode().equals(HttpStatus.OK) || responseEntity.getBody() == null) {
                throw new SecurityException("The result of token call returned error or the body returned null.");
            }
            AccessResponse accessResponse = responseEntity.getBody();
            Set<String> scopes = accessResponse.getScopes().isEmpty() ?
                    authorizationGrantRequest.getAuthorizationExchange().getAuthorizationRequest().getScopes() : accessResponse.getScopes();
            return OAuth2AccessTokenResponse.withToken(accessResponse.getAccessToken())
                    .tokenType(accessResponse.getTokenType())
                    .expiresIn(accessResponse.getExpiresIn())
                    .refreshToken(accessResponse.getRefreshToken())
                    .scopes(scopes)
                    .build();
        }
    
    }
    

    UserService

    public class RestOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
        private final RestOperations restOperations;
        private final TempUserDataService tempUserDataService;
        private final OAuth2AuthorizedClientRepo authorizedClientRepo;
    
    
        public RestOAuth2UserService(RestOperations restOperations, TempUserDataService tempUserDataService, OAuth2AuthorizedClientRepo authorizedClientRepo) {
            this.restOperations = restOperations;
            this.tempUserDataService = tempUserDataService;
            this.authorizedClientRepo = authorizedClientRepo;
        }
    
        @Override
        public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
            String userInfoUrl = oAuth2UserRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri();
            HttpHeaders headers = new HttpHeaders();
            headers.add(HttpHeaders.AUTHORIZATION, String.format("Bearer %s", oAuth2UserRequest.getAccessToken().getTokenValue()));
            headers.add(HttpHeaders.USER_AGENT, "Discord Bot 1.0");
            if (oAuth2UserRequest.getClientRegistration().getClientName().equals("OAuth2 Twitch")) {
                headers.add("client-id", oAuth2UserRequest.getClientRegistration().getClientId());
            }
            ParameterizedTypeReference<Map<String, Object>> typeReference = new ParameterizedTypeReference<Map<String, Object>>() {
            };
            ResponseEntity<Map<String, Object>> responseEntity = restOperations.exchange(userInfoUrl, HttpMethod.GET, new HttpEntity<>(headers), typeReference);
            if (!responseEntity.getStatusCode().equals(HttpStatus.OK) || responseEntity.getBody() == null) {
                throw new SecurityException("The result of token call returned error or the body returned null.");
            }
            Map<String, Object> userAttributes = responseEntity.getBody();
            userAttributes = LinkedHashMap.class.cast(((ArrayList) userAttributes.get("data")).get(0));
            OAuth2AuthorizedClientId clientId = new OAuth2AuthorizedClientId(oAuth2UserRequest.getClientRegistration().getRegistrationId(), String.valueOf(userAttributes.get("id")));
            Optional<OAuth2AuthorizedClientEntity> clientEntity = this.authorizedClientRepo.findById(clientId);
            if (!clientEntity.isPresent() || clientEntity.get().getUserDetails() == null) {
                TempUserData tempUserData = new TempUserData();
                tempUserData.setClientId(clientId);
                tempUserData.setUsername(String.valueOf(userAttributes.get("login")));
                tempUserData.setEmail(String.valueOf(userAttributes.get("email")));
                tempUserDataService.save(tempUserData);
            }
            Set<GrantedAuthority> authorities = Collections.singleton(new OAuth2UserAuthority(userAttributes));
            return new DefaultOAuth2User(authorities, userAttributes, oAuth2UserRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName());
        }
    

    As asked this is all the code you need, just to give you another hint. When you call /login/oauth2/code/facebook/?code=XXX&?state=XXX the chain is the following:

    1. RestOAuth2AccessTokenResponseClient
    2. RestOAuth2UserService
    3. TwitchOAuth2UrlAuthSuccessHandler

    I hope this can help you. Let me know if you need more explainations.