如何在 REST API 后端与 Spring 引导(单独的 React 前端)中使用社交登录(Facebook,Google)对用户进行身份验证

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

我目前正在开发 Spring Boot REST API。我已经使用具有 Spring Oauth 和 Spring 安全性的客户端凭据成功添加了登录(我可以使用 /oauth/token 端点成功获取访问令牌和刷新令牌)。 但现在我想通过 Facebook 和 Google 提供社交登录。据我了解,流程是这样的。

  1. 用户在 React 前端单击“使用社交登录”按钮。
  2. 然后,他将被要求授予访问权限。 (仍在 React 中)
  3. 之后,他将被重定向到带有访问令牌的 React 前端。
  4. 前端将该访问令牌发送到 Spring 引导后端。 (我不知道到什么端点)
  5. 然后后端使用该访问令牌从 Facebook/Google 中获取详细信息并检查我们的数据库中是否存在这样的用户。
  6. 如果存在这样的用户,后端将return访问并刷新前端的令牌。
  7. 现在前端可以使用所有端点。

我的问题是,我不知道步骤 4、5 和 6。 我是否必须创建自定义端点才能接收 FB/Google 访问令牌? 如何在 Spring 引导中发出自定义访问和刷新令牌?

如果你能帮助我解决这个问题,我将不胜感激。

流程如下:

  1. 前端调用 spring 到 /oauth2/authorization/facebook(或任何您想使用的客户端)
  2. 后端响应重定向到 Facebook 登录页面(包括在查询参数中,client_idscoperedirect_uri(必须与您的开发人员控制台上的相同)和 state 用于避免 XSRF 攻击,根据 OAuth2标准)

您可以在此处查看更多详细信息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. 一旦 facebook 或任何服务给你回电,你必须从 url(例如使用 JS)中获取这 2 个参数并调用 /login/oauth2/code/facebook/?code=CODE_GENERATED_BY_FACEBOOK&?state=STATE_GENERATED_BY_SPRING

  2. Spring 将调用 facebook 服务(使用 OAuth2AccessTokenResponseClient 的实现,使用您的 secret_token、client_id、代码和其他一些字段. 一旦 facebook 响应 access_token 和 refresh_token,spring 调用 OAuth2UserService 的实现,用于使用 access_token 创建的时刻从 facebook 获取用户信息之前,在 facebook 的响应中,将创建一个包含主体的会话。(您可以拦截登录成功,创建 SimpleUrlAuthenticationSuccessHandler 的实现并将其添加到您的 spring 安全配置中。(对于 facebook,google 和理论上的 otka OAuth2AccessTokenResponseClientOAuth2UserService 实现应该已经存在。

在该处理程序中,您可以放置​​逻辑来添加和查找现有用户。

回到默认行为

  1. 一旦 spring 创建了新会话并给了你 JSESSIONID cookie,它将把你重定向到根目录(我相信,我不记得登录后的默认路径是哪个,但是你可以更改它,创建我之前告诉过你的处理程序的你自己的实现)

注意:access_token和refresh_token将存储在OAuth2AuthorizedClient中,存储在ClientRegistrationRepository中。

到此结束。从现在开始,您可以使用该 cookie 调用您的后端,并且将把您视为登录用户。我的建议是,一旦您的简单流程开始运行,您应该实现一个 JWT 令牌来使用和存储在浏览器的本地存储中,而不是使用 cookie。

希望我给了你你正在寻找的信息,如果我遗漏了什么,误解了什么或不清楚的东西,请在评论中告诉我。

更新(一些java样本)

我的 OAuth2 安全配置:

注意:

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

事实上,我的 SessionCreationPolicy.STATELESS 在 OAuth2 流程结束后由 spring 创建的 cookie 是无用的。因此,一旦流程结束,我就会给用户一个 TemporaryJWT 令牌,用于访问唯一可能的服务(注册服务) 我的 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 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(负责从 FB 获取 Access_token 和 refresh_token)

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=XXX&?state=XXX 时,链如下:

  1. RestOAuth2AccessTokenResponseClient
  2. RestOAuth2UserService
  3. TwitchOAuth2UrlAuthSuccessHandler

希望对您有所帮助。如果您需要更多解释,请告诉我。