client_credentials模式接口鉴权


概述

OAuth 2是行业标准的授权协议。OAuth 2专注于客户端开发人员的简单性,同时为 Web 应用程序、桌面应用程序、移动电话和客厅设备提供特定的授权流程。
下面我们将使用OAuth2实现接口鉴权。

名词定义

在详细讲解OAuth 2之前,需要了解几个专用名词。它们对读懂后面的讲解,尤其是几张图,至关重要。
1、Third-party application:第三方应用程序。
2、HTTP service:HTTP服务提供商。
3、Resource Owner:资源所有者,本文中又称”用户”(user)。
4、User Agent:用户代理,本文中就是指浏览器。
5、Authorization server:认证服务器,即服务提供商专门用来处理认证的服务器。
6、Resource server:资源服务器,即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。

知道了上面这些名词,就不难理解,OAuth的作用就是让”客户端”安全可控地获取”用户”的授权,与”服务商提供商”进行互动。

OAuth的思路

OAuth在”客户端”与”服务提供商”之间,设置了一个授权层(authorization layer)。”客户端”不能直接登录”服务提供商”,只能登录授权层,以此将用户与客户端区分开来。”客户端”登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。

“客户端”登录授权层以后,”服务提供商”根据令牌的权限范围和有效期,向”客户端”开放用户储存的资料。

运行流程

OAuth 2.0的运行流程如下图,摘自RFC 6749[1]

+--------+                               +---------------+
|        |--(A)- Authorization Request ->|   Resource    |
|        |                               |     Owner     |
|        |<-(B)-- Authorization Grant ---|               |
|        |                               +---------------+
|        |
|        |                               +---------------+
|        |--(C)-- Authorization Grant -->| Authorization |
| Client |                               |     Server    |
|        |<-(D)----- Access Token -------|               |
|        |                               +---------------+
|        |
|        |                               +---------------+
|        |--(E)----- Access Token ------>|    Resource   |
|        |                               |     Server    |
|        |<-(F)--- Protected Resource ---|               |
+--------+                               +---------------+

项目

  • oauth2-server:oauth2认证服务器
  • oauth2-resource-server:资源服务器,接口所在的项目
  • oauth2-client:接口调用端

mysql ddl

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- 应用信息
-- ----------------------------
DROP TABLE IF EXISTS `app_info`;
CREATE TABLE `app_info`  (
  `app_id` int NOT NULL AUTO_INCREMENT COMMENT '应用id',
  `app_key` char(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '应用key',
  `app_secret` char(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '应用秘钥',
  `ip_whites` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'ip白名单列表,多个以英文逗号分割',
  PRIMARY KEY (`app_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '应用信息' ROW_FORMAT = Dynamic;

-- ----------------------------
-- oauth2 授权信息
-- ----------------------------
DROP TABLE IF EXISTS `oauth2_authorization`;
CREATE TABLE `oauth2_authorization`  (
  `id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `registered_client_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `principal_name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `authorization_grant_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `authorized_scopes` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `attributes` blob NULL,
  `state` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `authorization_code_value` blob NULL,
  `authorization_code_issued_at` timestamp NULL DEFAULT NULL,
  `authorization_code_expires_at` timestamp NULL DEFAULT NULL,
  `authorization_code_metadata` blob NULL,
  `access_token_value` blob NULL,
  `access_token_issued_at` timestamp NULL DEFAULT NULL,
  `access_token_expires_at` timestamp NULL DEFAULT NULL,
  `access_token_metadata` blob NULL,
  `access_token_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `access_token_scopes` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `oidc_id_token_value` blob NULL,
  `oidc_id_token_issued_at` timestamp NULL DEFAULT NULL,
  `oidc_id_token_expires_at` timestamp NULL DEFAULT NULL,
  `oidc_id_token_metadata` blob NULL,
  `refresh_token_value` blob NULL,
  `refresh_token_issued_at` timestamp NULL DEFAULT NULL,
  `refresh_token_expires_at` timestamp NULL DEFAULT NULL,
  `refresh_token_metadata` blob NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

oauth2-server

核心配置

package show.lmm.oauth2server.core.conf;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.RequestMatcher;
import show.lmm.oauth2server.core.DataHandler;
import show.lmm.oauth2server.core.handle.OAuth2AuthenticationFailureHandler;
import show.lmm.oauth2server.core.handle.OAuth2AuthenticationSuccessHandler;
import show.lmm.oauth2server.core.util.IpUtils;
import show.lmm.oauth2server.service.AppInfoService;

import java.time.Duration;
import java.util.*;

/**
 * oauth2服务器配置
 */
@Configuration
public class OAuth2ServerConfig {

    public static void applyDefaultSecurity(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
                new OAuth2AuthorizationServerConfigurer();

        authorizationServerConfigurer
                .tokenEndpoint(tokenEndpoint ->
                        tokenEndpoint.accessTokenRequestConverter(request -> {
                                    // IP白名单校验
                                    String ip = IpUtils.getIp(request);
                                    String clientId = request.getParameter("client_id");
                                    if (Optional.ofNullable(clientId).isEmpty()) {
                                        throw new OAuth2AuthenticationException("非法请求,错误码:1001");
                                    }
                                    Collection<String> ipWhites = DataHandler.appIpWhites.get(clientId);
                                    if (Optional.ofNullable(ipWhites).isEmpty() || ipWhites.isEmpty()) {
                                        throw new OAuth2AuthenticationException("非法请求,错误码:1002");
                                    }
                                    if (ipWhites.contains(ip)) {
                                        return null;
                                    }
                                    throw new OAuth2AuthenticationException(String.format("ip: %s,不在白名单", ip));
                                })
                                // 自定义accessToken返回值
                                .accessTokenResponseHandler(new OAuth2AuthenticationSuccessHandler())

                ).clientAuthentication(clientAuthenticationConfigurer ->
                        clientAuthenticationConfigurer
                                // 自定义异常返回值
                                .errorResponseHandler(new OAuth2AuthenticationFailureHandler())
                );

        RequestMatcher endpointsMatcher = authorizationServerConfigurer
                .getEndpointsMatcher();

        http
                .securityMatcher(endpointsMatcher)
                .authorizeHttpRequests(authorize ->
                        authorize.anyRequest().authenticated()
                )
                .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
                .apply(authorizationServerConfigurer);
    }

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
                new OAuth2AuthorizationServerConfigurer();
        http.apply(authorizationServerConfigurer);
        applyDefaultSecurity(http);
        return http
                .exceptionHandling(exceptions -> exceptions.
                        authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
                ).build();
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository(AppInfoService appInfoService) {
        List<RegisteredClient> registeredClients = new ArrayList<>();
        appInfoService.list().forEach(item -> {
            DataHandler.appIpWhites.put(item.getAppKey(), Arrays.asList(item.getIpWhites().split(",")));
            registeredClients.add(RegisteredClient.withId(UUID.randomUUID().toString())
                    .clientId(item.getAppKey())
                    .clientSecret(String.format("{noop}%s", item.getAppSecret()))
                    .clientAuthenticationMethods(s -> {
                        s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
                        s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
                    })
                    .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                    .scope("message.read")
                    .clientSettings(ClientSettings.builder()
                            .requireAuthorizationConsent(true)
                            .requireProofKey(false)
                            .build())
                    .tokenSettings(TokenSettings.builder()
                            // 生成不透明token
                            .accessTokenFormat(OAuth2TokenFormat.REFERENCE)
                            // 签名方式
                            .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
                            // accessToken 有效期
                            .accessTokenTimeToLive(Duration.ofHours(1))
                            .reuseRefreshTokens(true)
                            .build())
                    .build());
        });
        return new InMemoryRegisteredClientRepository(registeredClients);
    }

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
                .issuer("http://127.0.0.1:8081")
                .build();
    }
}

{noop} 表示密码不加密

获取accessToken示例

curl --location --request POST 'http://localhost:8081/oauth2/token?client_id=a53e8303bcd8441f8c8b3b708c0f3bf5&client_secret=fa8896185af841d687e90091254a0b7c&grant_type=client_credentials'

自定义accessToken返回值

/**
 * 认证成功 handler
 */
public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private static final String NO_RESPONSE_DATA_PARAM_NAME = "no_response_data";

    private final Converter<OAuth2AccessTokenResponse, Map<String, Object>> accessTokenResponseParametersConverter =
            new DefaultOAuth2AccessTokenResponseMapConverter();

    @SneakyThrows
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) {
        OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) authentication;
        OAuth2AccessToken accessToken = accessTokenAuthentication.getAccessToken();
        OAuth2RefreshToken refreshToken = accessTokenAuthentication.getRefreshToken();
        Map<String, Object> additionalParameters = accessTokenAuthentication.getAdditionalParameters();
        OAuth2AccessTokenResponse.Builder builder = OAuth2AccessTokenResponse.withToken(accessToken.getTokenValue())
                .tokenType(accessToken.getTokenType()).scopes(accessToken.getScopes());
        if (accessToken.getIssuedAt() != null && accessToken.getExpiresAt() != null) {
            builder.expiresIn(ChronoUnit.SECONDS.between(accessToken.getIssuedAt(), accessToken.getExpiresAt()));
        }
        if (refreshToken != null) {
            builder.refreshToken(refreshToken.getTokenValue());
        }
        if (!CollectionUtils.isEmpty(additionalParameters)) {
            builder.additionalParameters(additionalParameters);
        }
        OAuth2AccessTokenResponse accessTokenResponse = builder.build();
        Map<String, Object> tokenResponseParameters = this.accessTokenResponseParametersConverter.convert(accessTokenResponse);
        ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);

        boolean noResponseData = Boolean.parseBoolean(request.getParameter(NO_RESPONSE_DATA_PARAM_NAME));
        if(request.getHeader("user-agent").toLowerCase().contains("java")){
            noResponseData = true;
        }
        if (noResponseData) {
            HttpEndpointUtils.writeWithMessageConverters(tokenResponseParameters, httpResponse);
        } else {
            ReturnHandler<Map<String, Object>> returnHandler = ReturnHandler.ok(tokenResponseParameters);
            HttpEndpointUtils.writeWithMessageConverters(returnHandler, httpResponse);
        }
    }
}

自定义异常返回值

/**
 * 认证失败 handler
 */
@Component
public class OAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler {
    private static final String AUTHENTICATION_METHOD = "authentication_method";
    private static final String CREDENTIALS = "credentials";

    @SneakyThrows
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) {
        ReturnHandler<?> error = createError(exception);

        ServletServerHttpResponse servletServerHttpResponse = new ServletServerHttpResponse(response);
        servletServerHttpResponse.setStatusCode(HttpStatus.UNAUTHORIZED);
        HttpEndpointUtils.writeWithMessageConverters(error, servletServerHttpResponse);
    }

    /**
     * 创建 error
     *
     * @param exception /
     * @return /
     */
    private ReturnHandler<?> createError(AuthenticationException exception) {
        ReturnHandler<?> error = null;
        if (exception instanceof OAuth2AuthenticationException oAuth2AuthenticationException) {
            String errorCode = oAuth2AuthenticationException.getError().getErrorCode();
            String description = oAuth2AuthenticationException.getError().getDescription();
            switch (errorCode) {
                case OAuth2ErrorCodes.INVALID_CLIENT -> {
                    if (StringUtils.isBlank(description)) {
                        error = ReturnHandler.error(ResponseCode.INVALID_GRANT, "无效的客户端");
                    } else if (description.contains(OAuth2ParameterNames.CLIENT_ID)) {
                        error = ReturnHandler.error(ResponseCode.CLIENT_NOT_EXIST);
                    } else if (description.contains(AUTHENTICATION_METHOD)) {
                        error = ReturnHandler.error(ResponseCode.AUTHORIZATION_DENIED);
                    } else if (description.contains(CREDENTIALS)) {
                        error = ReturnHandler.error(ResponseCode.CLIENT_PASSWORD_EMPTY);
                    } else if (description.contains(OAuth2ParameterNames.CLIENT_SECRET)) {
                        error = ReturnHandler.error(ResponseCode.CLIENT_PASSWORD_INCORRECT);
                    }
                }
                case OAuth2ErrorCodes.UNSUPPORTED_GRANT_TYPE ->
                        error = ReturnHandler.error(ResponseCode.UNSUPPORTED_GRANT_TYPE);
                case OAuth2ErrorCodes.INVALID_REQUEST -> {
                    if (StringUtils.isBlank(description)) {
                        error = ReturnHandler.error(ResponseCode.INVALID_GRANT, "无效的客户端");
                    } else if (description.contains(OAuth2ParameterNames.GRANT_TYPE)) {
                        error = ReturnHandler.error(ResponseCode.GRANT_TYPE_EMPTY);
                    }
                }
                case OAuth2ErrorCodes.INVALID_GRANT -> error = ReturnHandler.error(ResponseCode.INVALID_GRANT);
                case OAuth2ErrorCodes.INVALID_SCOPE -> error = ReturnHandler.error(ResponseCode.INVALID_SCOPE);
                case OAuth2ErrorCodes.UNAUTHORIZED_CLIENT ->
                        error = ReturnHandler.error(ResponseCode.UNAUTHORIZED_CLIENT);
                default ->
                        error = ReturnHandler.error(ResponseCode.USER_LOGIN_ABNORMAL.getCode(), oAuth2AuthenticationException.getError().getErrorCode());
            }
        } else {
            error = ReturnHandler.error(ResponseCode.USER_LOGIN_ABNORMAL, exception.getLocalizedMessage());
        }

        return error;
    }
}

oauth2-resource-server

核心配置

/**
 * 资源服务器配置
 */
@Configuration(proxyBeanMethods = false)
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorize -> authorize
                        .anyRequest().authenticated())
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::opaqueToken);
        return http.build();
    }
}

配置文件 application.yml

server:
  port: 8082
spring:
  security:
    oauth2:
      resource-server:
        opaque-token:
          introspection-uri: http://127.0.0.1:8081/oauth2/introspect
          client-id: a53e8303bcd8441f8c8b3b708c0f3bf5
          client-secret: fa8896185af841d687e90091254a0b7c
logging:
  level:
    root: debug

api接口

@RestController
@RequestMapping("/user_info")
@PreAuthorize("hasAuthority('SCOPE_message.read')")
public class  UserInfoController  {

    @GetMapping("/get")
    public UserInfo getUserInfo(@RequestParam("user_type")int userType) {
        UserInfo userInfo = new UserInfo();
        userInfo.setUsername("测试用户");
        userInfo.setUserType(userType);
        return userInfo;
    }
}

oauth2-client

核心配置

/**
 * Spring Security 配置
 */
@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorizeRequests ->
                        authorizeRequests.anyRequest().permitAll()
                )
                .oauth2Client(withDefaults());
        return http.build();
    }

    /**
     * OAuth2 webClient
     */
    @Bean
    WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        return WebClient.builder()
                .filter(oauth2Client)
                .build();
    }

    @Bean
    OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository,
                                                          OAuth2AuthorizedClientService authorizedClientService) {

        OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder
                .builder()
                .clientCredentials()
                .build();
        AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
                new AuthorizedClientServiceOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientService);
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        return authorizedClientManager;
    }
}

使用示例

@Slf4j
@Component
public class UserInfoSchedule {
    private final WebClient webClient;

    public UserInfoSchedule(WebClient webClient) {
        this.webClient = webClient;
    }

    @Scheduled(cron = "0/2 * * * * ? ")
    public void query() {
        String result = this.webClient
                .get()
                .uri("http://127.0.0.1:8082/user_info/get?user_type=22")
                .attributes(ServletOAuth2AuthorizedClientExchangeFilterFunction.clientRegistrationId("messaging-client-model"))
                .retrieve()
                .bodyToMono(String.class)
                .block();
        log.info("Call resource server execution result: " + result);
    }
}

配置文件 application.yml

server:
  port: 8083
spring:
  security:
    oauth2:
      client:
        registration:
          messaging-client-model:
            provider: client-provider
            client-id: a53e8303bcd8441f8c8b3b708c0f3bf5
            client-secret: fa8896185af841d687e90091254a0b7c
            authorization-grant-type: client_credentials
            client-authentication-method: client_secret_post
            scope: message.read
            client-name: messaging-client-model
        provider:
          client-provider:
            token-uri: http://127.0.0.1:8081/oauth2/token

示例

https://gitee.com/luoye/examples/tree/main/spring-oauth2


文章作者: Ming Ming Liu
文章链接: https://www.lmm.show/30/
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Ming Ming Liu !
  目录