Как аутентифицировать пользователей с помощью социального входа (Facebook, Google) в бэкэнде REST API с загрузкой Spring (отдельный интерфейс React)

#reactjs #spring-boot #oauth #facebook-access-token

Вопрос:

В настоящее время я работаю над API Spring Boot REST. Я успешно добавил вход с использованием учетных данных клиента с помощью Spring Oauth и Spring Security (я могу успешно получить маркер доступа и обновить маркер с помощью /oauth/token конечной точки). Но теперь я хочу предоставить социальный логин с помощью Facebook и Google. Как я понимаю, это и есть поток.

  1. Пользователь нажимает кнопку Входа в систему с помощью социальной кнопки в интерфейсе React.
  2. Затем ему будет предложено предоставить доступ. (Все еще в состоянии реакции)
  3. После этого он будет перенаправлен на интерфейс react с токеном доступа.
  4. Интерфейс отправляет этот токен доступа на серверную часть Spring Boot. (Я не знаю, до какой конечной точки)
  5. Затем серверная часть использует этот токен доступа для получения сведений из Facebook/Google и проверки наличия такого пользователя в нашей базе данных.
  6. Если такой пользователь существует, серверная часть вернет токены доступа и обновления интерфейсу.
  7. Теперь интерфейс может использовать все конечные точки.

Моя проблема в том, что я понятия не имею о шагах 4,5 и 6. Нужно ли создавать пользовательскую конечную точку для получения токенов доступа FB/Google? Как я могу выдавать пользовательские маркеры доступа и обновления в Spring Boot?

Я был бы очень признателен, если бы вы помогли мне с этим сценарием.

Комментарии:

1. Конечная точка 2, созданная клиентом Spring OAuth2 /oauth2/authorization/YOUR_CLIENT_ID , используется для перенаправления пользователя на вход в социальную сеть и /login/oauth2/code/YOUR_CLIENT_ID/?code=CODE_GENERATED_BY_SPRINGamp;?state=STATE_GENERATED_BY_SPRING используется после того, как пользователь вошел в свою социальную сеть, эта служба вызывается для завершения потока входа в систему. Необходимо реализовать SimpleUrlAuthenticationSuccessHandler, чтобы проверить, находится ли пользователь в БД ( onAuthenticationSuccess метод) или нет.

2. Я не понял, есть ли у вас логин, основанный на имени пользователя и пароле.

3. Спасибо вам за комментарий. Да, у меня есть логин, основанный на имени пользователя и пароле. Но дело в том, что Spring boot используется для API REST. Мой интерфейс находится в другом домене (или поддомене). Поэтому, я полагаю, мне придется перенаправить с помощью React, получить маркер доступа и передать его в серверную часть, верно?

Ответ №1:

Поток это следующий:

  1. Интерфейсные вызовы поступают на /oauth2/authorization/facebook (или любой другой клиент, который вы хотите использовать)
  2. Серверная часть отвечает перенаправлением на страницу входа в Facebook(в том числе в параметрах запроса, client_id, области действия, redirect_uri(должно присутствовать то же самое на консоли разработчика) и состоянии, которое используется для предотвращения XSRF-атак, в соответствии со стандартами OAuth2)

более подробную информацию вы можете посмотреть здесь https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1

государство РЕКОМЕНДОВАЛО. Непрозрачное значение, используемое клиентом для поддержания состояния между запросом и обратным вызовом. Сервер авторизации включает это значение при перенаправлении агента пользователя обратно клиенту. Этот параметр СЛЕДУЕТ использовать для предотвращения подделки межсайтовых запросов, как описано в разделе 10.12. 3) Как только пользователь войдет в систему и примет любое всплывающее окно facebook или других сервисов, пользователь будет перенаправлен на страницу, представленную в «redirect_uri», эта страница должна быть компонентом вашего ReactJS. Обратный вызов будет сопровождаться некоторыми данными, указанными в параметрах запроса, обычно это два параметра: состояние(то же самое, что вы отправили на facebook) и код(который используется из BE для завершения процесса входа в систему).

  1. Как только facebook или какая-либо другая служба перезвонит вам, вы должны взять эти 2 параметра с URL-адреса(например, используя JS) и позвонить в /login/oauth2/code/facebook/?code=CODE_GENERATED_BY_FACEBOOKamp;?state=STATE_GENERATED_BY_SPRING
  2. Весна вызовет службу facebook(с реализацией OAuth2AccessTokenResponseClient , используя ваш secret_token, идентификатор клиента, код и несколько других полей. Как только facebook ответит с помощью access_token и refresh_token, spring вызовет реализацию OAuth2UserService , используемую для получения информации о пользователе из facebook с помощью access_token, созданного за мгновение до этого, в ответ на facebook будет создан сеанс, включающий принципала. (Вы можете перехватить успешный вход в систему, создав реализацию SimpleUrlAuthenticationSuccessHandler и добавив ее в конфигурацию безопасности spring. (Для facebook, google и otka в теории OAuth2AccessTokenResponseClient и OAuth2UserService реализациях уже должны существовать.

В этом обработчике вы можете указать логику добавления и поиска существующего пользователя.

возвращаемся к поведению по умолчанию

  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"));

Также обратите внимание, что я использую двойную конфигурацию HttpSecurity. (Но в данном случае это тоже бесполезно публиковать)

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

По тому факту, что мой SessionCreationPolicy.STATELESS файл cookie, созданный весной после окончания потока OAuth2, бесполезен. Поэтому, как только процесс завершен, я даю пользователю временный токен JWT, используемый для доступа к единственно возможной службе (службе регистрации).
Мой фильтр идентификации токенов:

 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 (Вот где происходит все волшебство):

Этот обработчик вызывается один раз службой пользователей, а служба пользователей вызывается при вызове пользователя api.myweb.com/login/oauth2/code/facebook/?code=XXXamp;state=XXX. (пожалуйста, не забывайте о штате)

 @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() amp;amp; 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 (он отвечает за получение доступа и обновления из 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();
    }

}
 

Обслуживание пользователей

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

Как и было сказано, это весь код, который вам нужен, просто чтобы дать вам еще одну подсказку. При вызове /login/oauth2/code/facebook/?code=XXXamp;?state=XXX цепочки происходит следующее:

  1. restoauth2accesstokenответчик
  2. restoauth2пользовательский сервис
  3. TwitchOAuth2UrlAuthSuccessHandler

Я надеюсь, что это может вам помочь. Дайте мне знать, если вам понадобятся дополнительные объяснения.

Комментарии:

1. Еще раз большое вам спасибо. У вас есть какие-нибудь примеры кодов?

2. У меня нет чистого, но если вам это нужно, я могу опубликовать образец, когда закончу работу .

3. Прежде чем я опубликую какой-нибудь код , мне нужно знать, хотите ли вы связать учетную запись (пользователя и пароль) с логином OAuth2. Например, настройка двойного входа в систему. Это не совсем ясно

4. Не совсем. Мое намерение состоит в том, чтобы сравнить электронную почту пользователя в Facebook с моей базой данных и войти в него или иным образом создать для него новую учетную запись, используя данные jis из Facebook. Является ли это обычной практикой? Или мне следует связать учетную запись Facebook с помощью facebook_id?

5. Я был бы очень признателен, если бы вы могли опубликовать какой-нибудь код.