如何:使用社群登入驗證

本指南說明如何使用社群登入提供者 (例如 Google、GitHub 等) 設定 Spring Authorization Server 以進行驗證。本指南的目的是示範如何使用 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 集合 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
上述範例示範了設定提供者 URL、用戶端 ID 和用戶端密鑰的**建議**方式,使用環境變數 (OKTA_BASE_URLOKTA_CLIENT_IDOKTA_CLIENT_SECRET)。如需更多資訊,請參閱 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.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());	// Enable OpenID Connect 1.0
		http
			// 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)
				)
			)
			// Accept access tokens for User Info and/or Client Registration
			.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 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,您現在可以將其移除。

進階使用案例

示範授權伺服器範例示範了用於聯合身份提供者的進階組態選項。從下列使用案例中選擇以查看每個案例的範例

在資料庫中擷取使用者

下列範例 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) {
		// 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();
}