Spring Boot Security OAuth2 Client + JustAuth 实现第三方登录

1. 引言

在现代Web应用中,第三方登录已成为提升用户体验的重要功能。用户无需记忆额外的账号密码,只需通过已有的社交账号(如微信、GitHub、Google等)即可快速登录,大大降低了注册门槛,提高了用户转化率。

Spring Security OAuth2 Client是Spring官方提供的OAuth2客户端实现,支持标准的OAuth2和OpenID Connect协议。而JustAuth是国内开源的第三方登录库,开箱即用,支持国内外数十家主流平台的第三方登录。

本文将详细介绍如何在Spring Boot 3.x项目中,结合Spring Security OAuth2 Client和JustAuth,实现微信等第三方登录功能。

2. 技术栈

  • Spring Boot 3.x
  • Spring Security 6.x
  • Spring Security OAuth2 Client
  • JustAuth 1.16.x

3. OAuth2与第三方登录原理

3.1 OAuth2授权流程

OAuth2是一种授权框架,允许第三方应用在用户授权下,获取有限的资源访问权限。标准OAuth2授权码模式的流程如下:

┌─────────┐                                ┌──────────────┐
│         │ 1. 用户点击第三方登录           │              │
│  用户   │ ──────────────────────────────>│  前端应用    │
│         │                                │              │
└─────────┘                                └──────────────┘
                                                  │
                    2. 重定向到授权服务器            │
                                                  ▼
                                           ┌──────────────┐
                                           │              │
                                           │  授权服务器  │
                                           │ (微信/GitHub)│
                                           │              │
                                           └──────────────┘
                                                  │
                    3. 用户授权后重定向回应用        │
                                                  ▼
┌─────────┐                                ┌──────────────┐
│         │ 4. 获取用户信息并登录           │              │
│  用户   │ <──────────────────────────────│  后端服务    │
│         │                                │              │
└─────────┘                                └──────────────┘

3.2 Spring Security OAuth2 Client && JustAuth

JustAuth是一个OAuth2的一个客户端库,它封装了OAuth2的请求,内置支持国内外数十家主流平台的第三方登录。相比于Spring Security OAuth2 Client,JustAuth的优势在于:

  • 开箱即用,无需手动配置各平台的回调URL等参数。
  • 支持国内外数十家主流平台的第三方登录,包括微信、GitHub、Google、Facebook等。
  • 提供了简单的API接口,方便集成到Spring Boot项目中。

Spring-boot-starter-oauth2-client是一个完整的OAuth2客户端实现,支持标准的OAuth2和OpenID Connect协议。它提供了丰富的配置选项,方便自定义授权流程、令牌管理等。

由于Spring Security OAuth2 Client的提供了完备性OAuth2-client流程,我们可以将JustAuth集成到Spring-boot-starter-oauth2-client中,即保留了Oauth2-client的完整性,同时又利用了JustAuth的便利性和兼容性,实现第三方登录功能。

4. 具体实现

4.1 创建Spring Boot项目(可选)

使用Spring Boot Initializr创建项目,添加以下依赖:

  • Spring Security
  • Spring Web
  • OAuth2 Client
  • Lombok

4.2 添加相关依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.8</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>oauth2-login-demo</artifactId>
    <version>1.0.0</version>
    <name>oauth2-login-demo</name>

    <properties>
        <java.version>21</java.version>
        <justauth.version>1.16.6</justauth.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

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

        <dependency>
            <groupId>me.zhyd.oauth</groupId>
            <artifactId>JustAuth</artifactId>
            <version>${justauth.version}</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

5. Spring Security OAuth2 Client集成JustAuth

5.1 自定义OAuth2ClientAuthExecutor授权执行器

创建JustAuth的授权执行器JustAuthExecutor.java

public class JustAuthExecutor implements OAuth2ClientAuthExecutor {
    private static final Pattern QUERY_PARAM_PATTERN = Pattern.compile("([^&=]+)(=?)([^&]+)?");
    private final Set<Class<?>> authRequestTypes = ClassUtil.scanPackage(ClassUtil.getPackage(AuthRequest.class));

    private AuthRequest getAuthRequest(ClientRegistration clientRegistration) {
        Map<String, Object> configurationMetadata = clientRegistration.getProviderDetails().getConfigurationMetadata();
        String productCode = (String) configurationMetadata.get(OAuth2ClientConstants.PRODUCT_CODE_NAME);
        if(StrUtil.isBlank(productCode)) {
            throw new OAuth2AuthenticationException("Missing product code in configuration metadata.");
        }
        Class<?> authRequestType = null;
        String typeName = "Auth" + productCode + "Request";
        for(Class<?> clz : authRequestTypes) {
            if (clz.getSimpleName().equalsIgnoreCase(typeName)) {
                authRequestType = clz;
                break;
            }
        }
        if(authRequestType == null) {
            throw new OAuth2AuthenticationException("[" + productCode + "] request type not found.");
        }

        String clientId = clientRegistration.getClientId();
        String clientSecret = clientRegistration.getClientSecret();
        String redirectUri = clientRegistration.getRedirectUri();
        AuthConfig.AuthConfigBuilder authConfigBuilder = AuthConfig.builder();
        authConfigBuilder.clientId(clientId)
                .clientSecret(clientSecret)
                .redirectUri(redirectUri);

        Map<String, Object> additionalParameters = (Map<String, Object>) configurationMetadata.get(OAuth2ClientConstants.PARAMETER_NAME_ADDITIONAL_PARAMETERS);
        if(MapUtil.isNotEmpty(additionalParameters)) {
            Set<String> methodNames = ClassUtil.getDeclaredMethodNames(authConfigBuilder.getClass());
            for(Map.Entry<String, Object> entry : additionalParameters.entrySet()) {
                String attributeName = entry.getKey();
                Object value = entry.getValue();
                if(methodNames.contains(attributeName) && value != null) {
                    ReflectUtil.invoke(authConfigBuilder, attributeName, value);
                }
            }
        }
        HttpConfig httpConfig = HttpConfig.builder().timeout(10000).build();// 延长超时时间
        AuthConfig authConfig = authConfigBuilder.httpConfig(httpConfig).build();

        return (AuthRequest) (AuthDefaultRequest) ReflectUtil.newInstance(authRequestType, authConfig);
    }

    public void authorizationRequest(ClientRegistration clientRegistration, OAuth2AuthorizationRequest.Builder builder) {
        OAuth2AuthorizationRequest authorizationRequest = builder.build();
        String state = authorizationRequest.getState();

        AuthRequest authRequest = getAuthRequest(clientRegistration);
        String authorizeUrl = authRequest.authorize(state);
        String query = URLUtil.toURI(authorizeUrl).getRawQuery();

        builder.parameters(parameters -> {
            // 删除旧数据
            parameters.remove(OAuth2ParameterNames.CLIENT_ID);
            parameters.remove(OAuth2ParameterNames.CLIENT_SECRET);

            Matcher matcher = QUERY_PARAM_PATTERN.matcher(query);
            while (matcher.find()) {
                String name = matcher.group(1);
                String eq = matcher.group(2);
                String value = matcher.group(3);
                if(name.equals(OAuth2ParameterNames.REDIRECT_URI) && value != null) {
                    value = URLUtil.decode(value);
                }
                parameters.put(name, (value != null ? value : (StringUtils.hasLength(eq) ? "" : null)));
            }
        });
    }

    @Override
    public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {
        ClientRegistration clientRegistration = authorizationCodeGrantRequest.getClientRegistration();
        String code = authorizationCodeGrantRequest.getAuthorizationExchange().getAuthorizationResponse().getCode();
        HttpServletRequest request = WebUtil.getRequest();
        Map<String, String[]> parameterMap = request.getParameterMap();
        Map<String, Object> parameters = MapUtil.newHashMap();
        for(Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
            String[] value = entry.getValue();
            String key = entry.getKey();
            if(ArrayUtil.isNotEmpty(value)) {
                if(value.length == 1) {
                    parameters.put(key, value[0]);
                }else {
                    parameters.put(key, value);
                }
            }
        }
        AuthCallback authCallback = BeanUtil.toBeanIgnoreError(parameters, AuthCallback.class);

        AuthToken authToken = getAuthRequest(clientRegistration).getAccessToken(authCallback);

        OAuth2AccessTokenResponse.Builder builder = OAuth2AccessTokenResponse.withToken(authToken.getAccessToken());
        builder.tokenType(OAuth2AccessToken.TokenType.BEARER);
        builder.refreshToken(authToken.getRefreshToken());
        builder.expiresIn(authToken.getExpireIn());

        Map<String, Object> additionalParameters = new HashMap<>();
        additionalParameters.put(OAuth2ParameterNames.CODE, code);
        additionalParameters.put(OAuth2ClientConstants.PARAMETER_NAME_AUTH_TOKEN, authToken);
        builder.additionalParameters(additionalParameters);

        return builder.build();
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) {
        ClientRegistration clientRegistration = userRequest.getClientRegistration();
        AuthToken authToken = (AuthToken) userRequest.getAdditionalParameters().get(OAuth2ClientConstants.PARAMETER_NAME_AUTH_TOKEN);
        AuthUser authUser = getAuthRequest(clientRegistration).getUserInfo(authToken);

        List<GrantedAuthority> grantedAuthorities = CollectionUtil.newArrayList();
        for (String scope : userRequest.getClientRegistration().getScopes()) {
            grantedAuthorities.add(new SimpleGrantedAuthority(scope));
        }

        Map<String, Object> attributes = BeanUtil.beanToMap(authUser);

        return new DefaultOAuth2User(grantedAuthorities, attributes, LambdaUtil.getFieldName(AuthUser::getUuid));
    }

}

5.2 自定义OAuth2AuthorizationRequestResolver,集成OAuth2ClientAuthExecutor进行自定义授权请求

OAuth2AuthorizationRequestCustomizer.java

@RequiredArgsConstructor
public class OAuth2AuthorizationRequestCustomizer implements Consumer<Builder> {

	private final ClientRegistrationRepository clientRegistrationRepository;

	@Override
	public void accept(Builder builder) {
		StringBuilder registrationId = new StringBuilder();
		builder.attributes(attributes -> {
			registrationId.append(attributes.get(OAuth2ParameterNames.REGISTRATION_ID));
		});
		ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(registrationId.toString());
		OAuth2ClientAuthExecutor authExecutor = OAuth2ClientAuthExecutor.getAuthExecutor(clientRegistration);
		if(ObjUtil.isNull(authExecutor)) {
			return;
		}
		authExecutor.authorizationRequest(clientRegistration, builder);
	}
}

5.3 自定义OAuth2AccessTokenResponseClient客户端,集成OAuth2ClientAuthExecutor进行自定义访问令牌响应

创建OAuth2ClientAuthorizationCodeTokenResponseClient.java

public class OAuth2ClientAuthorizationCodeTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
	private final RestClientAuthorizationCodeTokenResponseClient delegating = new RestClientAuthorizationCodeTokenResponseClient();

	public OAuth2ClientAuthorizationCodeTokenResponseClient() {
		OAuth2AccessTokenResponseHttpMessageConverter accessTokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
		accessTokenResponseHttpMessageConverter.setSupportedMediaTypes(List.of(MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON, new MediaType("application", "*+json")));

		RestClient restClient = RestClient.builder()
				.messageConverters((messageConverters) -> {
					messageConverters.clear();
					messageConverters.add(new FormHttpMessageConverter());
					messageConverters.add(accessTokenResponseHttpMessageConverter);
				})
				.defaultStatusHandler(new OAuth2ErrorResponseErrorHandler())
				.build();

		delegating.setRestClient(restClient);
	}

	@Override
	public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {
		ClientRegistration clientRegistration = authorizationCodeGrantRequest.getClientRegistration();
		OAuth2ClientAuthExecutor authExecutor = OAuth2ClientAuthExecutor.getAuthExecutor(clientRegistration);
		if(ObjUtil.isNull(authExecutor)) {
			return delegating.getTokenResponse(authorizationCodeGrantRequest);
		}

		return authExecutor.getTokenResponse(authorizationCodeGrantRequest);
	}

}

5.4 自定义OAuth2用户服务,集成OAuth2ClientAuthExecutor进行自定义用户加载

创建Oauth2ClientUserService.java

public class Oauth2ClientUserService extends DefaultOAuth2UserService {

	public Oauth2ClientUserService() {
		MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
		messageConverter.setSupportedMediaTypes(List.of(MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON, new MediaType("application", "*+json")));
		RestTemplate restTemplate = new RestTemplate(List.of(messageConverter));
		restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
		super.setRestOperations(restTemplate);
	}

	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		ClientRegistration clientRegistration = userRequest.getClientRegistration();
		OAuth2ClientAuthExecutor oAuth2ClientAuthExecutor = OAuth2ClientAuthExecutor.getAuthExecutor(clientRegistration);
		if(ObjUtil.isNull(oAuth2ClientAuthExecutor)) {
			return super.loadUser(userRequest);
		}
		return oAuth2ClientAuthExecutor.loadUser(userRequest);
	}

}

6. JustAuth实现微信登录

微信登录采用定制化的OAuth2协议,与标准协议存在差异。使用JustAuth可以大大简化接入流程。

6.1 微信开放平台配置

  1. 注册微信开放平台账号:https://open.weixin.qq.com
  2. 创建网站应用,获取AppID和AppSecret
  3. 配置授权回调域名

6.2 配置执行器名称和产品代码

在configuration_metadata中配置authExecutorName和productCode,例如:

{"@class":"java.util.Collections$UnmodifiableMap","authExecutorName":"JustAuthExecutor","productCode": "WeChatOpen"}

7. 参考资源

完整代码请参考项目仓库:mwc-security-authentication-server

演示地址:mwcs

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐