如何:使用社交登录进行身份验证

本指南展示了如何配置Spring授权服务器与社交登录提供程序(如Google、GitHub等)进行身份验证。本指南的目的是演示如何将表单登录替换为OAuth 2.0登录

Spring授权服务器是基于Spring Security构建的,我们将在整个指南中使用Spring Security的概念。

注册社交登录提供程序

要开始,您需要在所选的社交登录提供程序上设置一个应用程序。常见的提供程序包括:

按照您的提供程序的步骤操作,直到要求指定重定向URI。要设置重定向URI,请选择一个registrationId(例如googlemy-client或任何其他您希望使用的唯一标识符),您将用它来配置Spring Security您的提供程序。

registrationId是Spring Security中ClientRegistration的唯一标识符。默认的重定向URI模板是{baseUrl}/login/oauth2/code/{registrationId}。有关更多信息,请参阅Spring Security参考中的设置重定向URI
例如,在本地端口9000上进行测试,使用registrationIdgoogle,您的重定向URI将是localhost:9000/login/oauth2/code/google。在设置应用程序时,请将此值作为重定向URI输入到您的提供程序中。

完成与您的社交登录提供程序的设置过程后,您应该已经获得了凭据(客户端ID和客户端密钥)。此外,您需要参考提供程序的文档,并注意以下值:

  • 授权URI:用于在提供程序上启动authorization_code流的端点。

  • 令牌URI:用于交换authorization_code以获取access_token和可选的id_token的端点。

  • JWK集URI:用于获取用于验证JWT签名的密钥的端点,当id_token可用时需要。

  • 用户信息URI:用于获取用户信息的端点,当id_token不可用时需要。

  • 用户名属性:包含用户用户名的id_token或用户信息响应中的声明。

配置OAuth 2.0登录

一旦您已经与社交登录提供商注册,您可以继续配置Spring Security以进行OAuth 2.0登录

添加OAuth2客户端依赖

首先,添加以下依赖项:

  • Maven

  • Gradle

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"

注册客户端

接下来,使用之前获取的值配置ClientRegistration。以Okta为例,配置以下属性:

application.yml
okta:
  base-url: ${OKTA_BASE_URL}

spring:
  security:
    oauth2:
      client:
        registration:
          my-client:
            provider: okta
            client-id: ${OKTA_CLIENT_ID}
            client-secret: ${OKTA_CLIENT_SECRET}
            scope:
              - openid
              - profile
              - email
        provider:
          okta:
            authorization-uri: ${okta.base-url}/oauth2/v1/authorize
            token-uri: ${okta.base-url}/oauth2/v1/token
            user-info-uri: ${okta.base-url}/oauth2/v1/userinfo
            jwk-set-uri: ${okta.base-url}/oauth2/v1/keys
            user-name-attribute: sub
上面示例中的registrationIdmy-client
上面的示例演示了使用环境变量(OKTA_BASE_URLOKTA_CLIENT_IDOKTA_CLIENT_SECRET)设置提供者URL、客户端ID和客户端密钥的推荐方式。有关更多信息,请参阅Spring Boot参考中的外部化配置

这个简单示例演示了典型的配置,但有些提供商可能需要额外的配置。有关配置ClientRegistration的更多信息,请参阅Spring Security参考中的Spring Boot属性映射

配置认证

最后,要配置Spring授权服务器以使用社交登录提供商进行认证,您可以使用oauth2Login()代替formLogin()。您还可以通过配置exceptionHandling()AuthenticationEntryPoint来自动将未经身份验证的用户重定向到提供者。

继续我们的之前的示例,配置Spring Security使用@Configuration如下示例:

配置OAuth 2.0登录
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean (1)
	@Order(1)
	public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
			throws Exception {
		OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
		http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
			.oidc(Customizer.withDefaults());	// 启用OpenID Connect 1.0
		http
			// 未经身份验证时从授权端点重定向到OAuth 2.0登录端点
			.exceptionHandling((exceptions) -> exceptions
				.defaultAuthenticationEntryPointFor( (2)
					new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/my-client"),
					new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
				)
			)
			// 接受用户信息和/或客户端注册的访问令牌
			.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));

		return http.build();
	}

	@Bean (3)
	@Order(2)
	public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
			throws Exception {
		http
			.authorizeHttpRequests((authorize) -> authorize
				.anyRequest().authenticated()
			)
			// OAuth2登录处理重定向到OAuth 2.0登录端点
			// 从授权服务器过滤器链
			.oauth2Login(Customizer.withDefaults()); (4)

		return http.build();
	}

}
1 用于协议端点的Spring Security过滤器链。
2 配置用于重定向到OAuth 2.0登录端点AuthenticationEntryPoint
3 用于认证的Spring Security过滤器链。
4 配置用于认证的OAuth 2.0登录

如果在开始时配置了UserDetailsService,现在可以将其移除。

高级用例

演示授权服务器示例演示了用于联合身份提供者的高级配置选项。从以下用例中选择一个示例:

在数据库中捕获用户

以下示例AuthenticationSuccessHandler使用自定义组件在用户首次登录时将用户捕获到本地数据库中:

FederatedIdentityAuthenticationSuccessHandler
import java.io.IOException;
import java.util.function.Consumer;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
public final class FederatedIdentityAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

	private final AuthenticationSuccessHandler delegate = new SavedRequestAwareAuthenticationSuccessHandler();

	private Consumer<OAuth2User> oauth2UserHandler = (user) -> {};

	private Consumer<OidcUser> oidcUserHandler = (user) -> this.oauth2UserHandler.accept(user);

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
		if (authentication instanceof OAuth2AuthenticationToken) {
			if (authentication.getPrincipal() instanceof OidcUser) {
				this.oidcUserHandler.accept((OidcUser) authentication.getPrincipal());
			} else if (authentication.getPrincipal() instanceof OAuth2User) {
				this.oauth2UserHandler.accept((OAuth2User) authentication.getPrincipal());
			}
		}

		this.delegate.onAuthenticationSuccess(request, response, authentication);
	}

	public void setOAuth2UserHandler(Consumer<OAuth2User> oauth2UserHandler) {
		this.oauth2UserHandler = oauth2UserHandler;
	}

	public void setOidcUserHandler(Consumer<OidcUser> oidcUserHandler) {
		this.oidcUserHandler = oidcUserHandler;
	}

}

使用上述AuthenticationSuccessHandler,您可以插入自己的Consumer<OAuth2User>,用于将用户捕获到数据库或其他数据存储中,用于联合账户链接或JIT账户提供等概念。以下是一个简单将用户存储在内存中的示例:

UserRepositoryOAuth2UserHandler
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;

import org.springframework.security.oauth2.core.user.OAuth2User;
public final class UserRepositoryOAuth2UserHandler implements Consumer<OAuth2User> {

	private final UserRepository userRepository = new UserRepository();

	@Override
	public void accept(OAuth2User user) {
		// 在首次认证时将用户捕获到本地数据存储中
		if (this.userRepository.findByName(user.getName()) == null) {
			System.out.println("保存首次用户:名称=" + user.getName() + ",声明=" + user.getAttributes() + ",权限=" + user.getAuthorities());
			this.userRepository.save(user);
		}
	}

	static class UserRepository {

		private final Map<String, OAuth2User> userCache = new ConcurrentHashMap<>();

		public OAuth2User findByName(String name) {
			return this.userCache.get(name);
		}

		public void save(OAuth2User oauth2User) {
			this.userCache.put(oauth2User.getName(), oauth2User);
		}

	}

}

将声明映射到ID令牌

以下示例OAuth2TokenCustomizer将用户的声明从认证提供者映射到Spring授权服务器生成的id_token

FederatedIdentityIdTokenCustomizer
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
public final class FederatedIdentityIdTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {

	private static final Set<String> ID_TOKEN_CLAIMS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
			IdTokenClaimNames.ISS,
			IdTokenClaimNames.SUB,
			IdTokenClaimNames.AUD,
			IdTokenClaimNames.EXP,
			IdTokenClaimNames.IAT,
			IdTokenClaimNames.AUTH_TIME,
			IdTokenClaimNames.NONCE,
			IdTokenClaimNames.ACR,
			IdTokenClaimNames.AMR,
			IdTokenClaimNames.AZP,
			IdTokenClaimNames.AT_HASH,
			IdTokenClaimNames.C_HASH
	)));

	@Override
	public void customize(JwtEncodingContext context) {
		if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
			Map<String, Object> thirdPartyClaims = extractClaims(context.getPrincipal());
			context.getClaims().claims(existingClaims -> {
				// 删除此授权服务器设置的冲突声明
				existingClaims.keySet().forEach(thirdPartyClaims::remove);

				// 删除可能会导致客户端问题的标准id_token声明
				ID_TOKEN_CLAIMS.forEach(thirdPartyClaims::remove);

				// 直接将所有其他声明添加到id_token
				existingClaims.putAll(thirdPartyClaims);
			});
		}
	}

	private Map<String, Object> extractClaims(Authentication principal) {
		Map<String, Object> claims;
		if (principal.getPrincipal() instanceof OidcUser) {
			OidcUser oidcUser = (OidcUser) principal.getPrincipal();
			OidcIdToken idToken = oidcUser.getIdToken();
			claims = idToken.getClaims();
		} else if (principal.getPrincipal() instanceof OAuth2User) {
			OAuth2User oauth2User = (OAuth2User) principal.getPrincipal();
			claims = oauth2User.getAttributes();
		} else {
			claims = Collections.emptyMap();
		}

		return new HashMap<>(claims);
	}

}

您可以通过将其发布为@Bean来配置Spring授权服务器以使用此自定义程序,示例如下:

配置FederatedIdentityIdTokenCustomizer
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> idTokenCustomizer() {
    return new FederatedIdentityIdTokenCustomizer();
}