操作指南:使用社交登入進行認證

本指南展示瞭如何配置 Spring Authorization Server 使用社交登入提供商(如 Google、GitHub 等)進行認證。本指南的目的是演示如何將表單登入替換為OAuth 2.0 登入

Spring Authorization Server 基於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 Set URI:用於獲取金鑰以驗證 JWT 簽名的端點,當 id_token 可用時需要此項。

  • 使用者資訊 URI:用於獲取使用者資訊的端點,當 id_token 不可用時需要此項。

  • 使用者名稱屬性:id_token 或使用者資訊響應中包含使用者名稱的宣告(claim)。

配置 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 Authorization Server 使用社交登入提供商進行認證,您可以使用 oauth2Login() 代替 formLogin()。您還可以透過使用 AuthenticationEntryPoint 配置 exceptionHandling() 將未認證使用者自動重定向到提供商。

繼續我們的之前的示例,使用 `@Configuration` 配置 Spring Security,示例如下

配置 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.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 {
		OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
				OAuth2AuthorizationServerConfigurer.authorizationServer();

		http
			.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
			.with(authorizationServerConfigurer, (authorizationServer) ->
				authorizationServer
					.oidc(Customizer.withDefaults())	// Enable OpenID Connect 1.0
			)
			.authorizeHttpRequests((authorize) ->
				authorize
					.anyRequest().authenticated()
			)
			// Redirect to the OAuth 2.0 Login endpoint when not authenticated
			// from the authorization endpoint
			.exceptionHandling((exceptions) -> exceptions
				.defaultAuthenticationEntryPointFor( (2)
					new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/my-client"),
					new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
				)
			);

		return http.build();
	}

	@Bean (3)
	@Order(2)
	public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
			throws Exception {
		http
			.authorizeHttpRequests((authorize) -> authorize
				.anyRequest().authenticated()
			)
			// OAuth2 Login handles the redirect to the OAuth 2.0 Login endpoint
			// from the authorization server filter chain
			.oauth2Login(Customizer.withDefaults()); (4)

		return http.build();
	}

}
1 協議端點的 Spring Security 過濾器鏈。
2 配置 AuthenticationEntryPoint 以重定向到OAuth 2.0 登入端點
3 認證的 Spring Security 過濾器鏈。
4 配置OAuth 2.0 登入進行認證。

如果您在入門時配置了 UserDetailsService,現在可以將其移除。

高階用例

demo 授權伺服器示例演示了聯合身份提供商的高階配置選項。從以下用例中選擇以檢視每個示例

  • 我想將使用者儲存在資料庫中

  • 我想將宣告對映到 ID 令牌

將使用者儲存在資料庫中

以下示例 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 賬戶 Provisioning 等概念。這是一個簡單地將使用者儲存在記憶體中的示例

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) {
		// Capture user in a local data store on first authentication
		if (this.userRepository.findByName(user.getName()) == null) {
			System.out.println("Saving first-time user: name=" + user.getName() + ", claims=" + user.getAttributes() + ", authorities=" + 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 Authorization Server 生成的 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 -> {
				// Remove conflicting claims set by this authorization server
				existingClaims.keySet().forEach(thirdPartyClaims::remove);

				// Remove standard id_token claims that could cause problems with clients
				ID_TOKEN_CLAIMS.forEach(thirdPartyClaims::remove);

				// Add all other claims directly to 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 Authorization Server 使用此定製器,示例如下

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