oauth2.0 返回签名JWT的userinfo端点的Spring失败

voj3qocg  于 2022-12-03  发布在  Spring
关注(0)|答案(2)|浏览(124)

我们正在开发一个Sping Boot 应用程序,它是一个OIDC客户端。身份提供者(IdP)是一个第三方服务,完全兼容OpenID Connect和OAuth 2.0(就我们所知)。由于它是以高安全性为目标构建的,它的 UserInfo 端点返回一个 * 已签名 * 的JWT(而不是常规的JWT)。
Spring Security似乎不支持它。身份验证流以一条错误消息结束(显示在Spring应用程序生成的HTML页面中):
[invalid_user_info_response]尝试检索用户信息资源时出错:无法提取响应:未找到适合响应类型[java.util.Map]和内容类型[application/jwt;字符集=UTF-8]
我的疑问:

  • Spring目前不支持返回已签名JWT的 UserInfo 端点,这对吗?
  • 如果是,我们如何添加对已签名JWT的支持(包括签名验证)?

我们的分析显示DefaultOAuth2UserService请求(Accept: application/json)并期望IdP的JSON响应。但是,由于IdP配置为高安全性,因此它返回一个内容类型为application/jwt的签名JWT。该响应类似于jwt.io上的示例。由于RestTemplate没有能够处理内容类型application/jwt的消息转换器,因此验证失败。
我们的示例应用程序非常简单:

构建.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.2.4.RELEASE'
    id 'io.spring.dependency-management' version '1.0.9.RELEASE'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}

演示应用程序.java

package demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

应用程序.yml

server:
  port: 8081

spring:
  security:
    oauth2:
      client:
        registration:
          demo:
            client-id: our-client-id
            client-secret: our-client-secret
            clientAuthenticationMethod: post
            provider: our-idp
            scope:
              - profile
              - email
        provider:
          our-idp:
            issuer-uri: https://login.idp.com:443/idp/oauth2

家庭控制器.java

package demo;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HomeController {
    @GetMapping("/")
    String hello() { return "hello"; }
}
hkmswyz6

hkmswyz61#

经过更多的分析,似乎Sping Boot 不支持 UserInfo 端点返回签名的JWT。这显然是一个不寻常的设置(但仍然在OAuth 2.0 / OIDC规范范围内)。到目前为止我还没有提到的是,JWT是用 client secret 签名的。
当Sping Boot 不支持它时,可以添加它。解决方案包括:

  • 支持已签名JWT的用户服务(作为DefaultOAuth2UserService的替代)
  • 支持JWT的HttpMessageConverter(用于用户服务的RestTemplate
  • 使用客户端密码的JwtDecoder
  • 将各个部分组合在一起的安全配置

请注意,我们同时已从OAuth 2.0更改为OIDC,因此我们的application.yml现在包括openid范围。

spring:
  security:
    oauth2:
      client:
        registration:
          demo:
            client-id: our-client-id
            client-secret: our-client-secret
            clientAuthenticationMethod: post
            provider: our-idp
            scope:
              - profile
              - email
        provider:
          our-idp:
            issuer-uri: https://login.idp.com:443/idp/oauth2

安全配置为:

package demoapp;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final ClientRegistrationRepository clientRegistrationRepository;

    public SecurityConfig(ClientRegistrationRepository clientRegistrationRepository) {
        this.clientRegistrationRepository = clientRegistrationRepository;
    }

    @Override
    protected  void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                .oauth2Login()
                    .userInfoEndpoint()
                        .oidcUserService(oidcUserService());
    }

    @Bean
    OidcUserService oidcUserService() {
        OidcUserService userService = new OidcUserService();
        userService.setOauth2UserService(new ValidatingOAuth2UserService(jwtDecoderUsingClientSecret("demo")));
        return userService;
    }

    JwtDecoder jwtDecoderUsingClientSecret(String registrationId) {
        ClientRegistration registration = clientRegistrationRepository.findByRegistrationId(registrationId);
        SecretKeySpec key = new SecretKeySpec(registration.getClientSecret().getBytes(StandardCharsets.UTF_8), "HS256");
        return NimbusJwtDecoder.withSecretKey(key).build();
    }
}

如果使用OAuth 2.0而不是OIDC(即不使用作用域“openid”),配置会更简单:

package demo;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final ClientRegistrationRepository clientRegistrationRepository;

    public SecurityConfig(ClientRegistrationRepository clientRegistrationRepository) {
        this.clientRegistrationRepository = clientRegistrationRepository;
    }

    @Override
    protected  void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                .oauth2Login()
                    .userInfoEndpoint()
                        .userService(new ValidatingOAuth2UserService(jwtDecoderUsingClientSecret("demo")));
    }

    JwtDecoder jwtDecoderUsingClientSecret(String registrationId) {
        ClientRegistration registration = clientRegistrationRepository.findByRegistrationId(registrationId);
        SecretKeySpec key = new SecretKeySpec(registration.getClientSecret().getBytes(StandardCharsets.UTF_8), "HS256");
        return NimbusJwtDecoder.withSecretKey(key).build();
    }
}

ValidatingOAuth2UserService类在很大程度上是DefaultOAuth2UserService的副本:

/*
 * Copyright 2002-2018 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package demo;

import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;

import org.springframework.core.convert.converter.Converter;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequestEntityConverter;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2UserAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;

/**
 * An implementation of an {@link OAuth2UserService} that supports standard OAuth 2.0 Provider's.
 * <p>
 *     This provider supports <i>UserInfo</i> endpoints returning user details
 *     in signed JWTs (content-type {@code application/jwt}).
 * </p>
 * <p>
 * For standard OAuth 2.0 Provider's, the attribute name used to access the user's name
 * from the UserInfo response is required and therefore must be available via
 * {@link ClientRegistration.ProviderDetails.UserInfoEndpoint#getUserNameAttributeName() UserInfoEndpoint.getUserNameAttributeName()}.
 * <p>
 * <b>NOTE:</b> Attribute names are <b>not</b> standardized between providers and therefore will vary.
 * Please consult the provider's API documentation for the set of supported user attribute names.
 *
 * @see org.springframework.security.oauth2.client.userinfo.OAuth2UserService
 * @see OAuth2UserRequest
 * @see OAuth2User
 * @see DefaultOAuth2User
 */
public class ValidatingOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private static final String MISSING_USER_INFO_URI_ERROR_CODE = "missing_user_info_uri";

    private static final String MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE = "missing_user_name_attribute";

    private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";

    private Converter<OAuth2UserRequest, RequestEntity<?>> requestEntityConverter = new OAuth2UserRequestEntityConverter();

    private RestOperations restOperations;
    private JwtDecoder jwtDecoder;

    public ValidatingOAuth2UserService(JwtDecoder jwtDecoder) {
        this.jwtDecoder = jwtDecoder;
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
        restTemplate.getMessageConverters().add(new JwtHttpMessageConverter());
        this.restOperations = restTemplate;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        Assert.notNull(userRequest, "userRequest cannot be null");

        if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
            OAuth2Error oauth2Error = new OAuth2Error(
                    MISSING_USER_INFO_URI_ERROR_CODE,
                    "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " +
                            userRequest.getClientRegistration().getRegistrationId(),
                    null
            );
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        }
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                .getUserInfoEndpoint().getUserNameAttributeName();
        if (!StringUtils.hasText(userNameAttributeName)) {
            OAuth2Error oauth2Error = new OAuth2Error(
                    MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
                    "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " +
                            userRequest.getClientRegistration().getRegistrationId(),
                    null
            );
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        }

        RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);

        ResponseEntity<String> response;
        try {
            response = this.restOperations.exchange(request, String.class);
        } catch (OAuth2AuthorizationException ex) {
            OAuth2Error oauth2Error = ex.getError();
            StringBuilder errorDetails = new StringBuilder();
            errorDetails.append("Error details: [");
            errorDetails.append("UserInfo Uri: ").append(
                    userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri());
            errorDetails.append(", Error Code: ").append(oauth2Error.getErrorCode());
            if (oauth2Error.getDescription() != null) {
                errorDetails.append(", Error Description: ").append(oauth2Error.getDescription());
            }
            errorDetails.append("]");
            oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
                    "An error occurred while attempting to retrieve the UserInfo Resource: " + errorDetails.toString(), null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
        } catch (RestClientException ex) {
            OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
                    "An error occurred while attempting to retrieve the UserInfo Resource: " + ex.getMessage(), null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
        }

        Jwt jwt = decodeAndValidateJwt(response.getBody());

        Map<String, Object> userAttributes = jwt.getClaims();
        Set<GrantedAuthority> authorities = new LinkedHashSet<>();
        authorities.add(new OAuth2UserAuthority(userAttributes));
        OAuth2AccessToken token = userRequest.getAccessToken();
        for (String authority : token.getScopes()) {
            authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
        }

        return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
    }

    private Jwt decodeAndValidateJwt(String token) {
        return jwtDecoder.decode(token);
    }

    /**
     * Sets the {@link Converter} used for converting the {@link OAuth2UserRequest}
     * to a {@link RequestEntity} representation of the UserInfo Request.
     *
     * @since 5.1
     * @param requestEntityConverter the {@link Converter} used for converting to a {@link RequestEntity} representation of the UserInfo Request
     */
    public final void setRequestEntityConverter(Converter<OAuth2UserRequest, RequestEntity<?>> requestEntityConverter) {
        Assert.notNull(requestEntityConverter, "requestEntityConverter cannot be null");
        this.requestEntityConverter = requestEntityConverter;
    }

    /**
     * Sets the {@link RestOperations} used when requesting the UserInfo resource.
     *
     * <p>
     * <b>NOTE:</b> At a minimum, the supplied {@code restOperations} must be configured with the following:
     * <ol>
     *  <li>{@link ResponseErrorHandler} - {@link OAuth2ErrorResponseErrorHandler}</li>
     * </ol>
     *
     * @since 5.1
     * @param restOperations the {@link RestOperations} used when requesting the UserInfo resource
     */
    public final void setRestOperations(RestOperations restOperations) {
        Assert.notNull(restOperations, "restOperations cannot be null");
        this.restOperations = restOperations;
    }
}

最后是JwtHttpMessageConverter类:

package demo;

import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.AbstractGenericHttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;

/**
 * Message converter for reading JWTs transmitted with content type {@code application/jwt}.
 * <p>
 *     The JWT is returned as a string and not validated.
 * </p>
 */
public class JwtHttpMessageConverter extends AbstractGenericHttpMessageConverter<String> {

    public JwtHttpMessageConverter() {
        super(MediaType.valueOf("application/jwt"));
    }

    @Override
    protected String readInternal(Class<? extends String> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
        return getBodyAsString(inputMessage.getBody());
    }

    @Override
    public String read(Type type, Class<?> contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
        return readInternal(null, inputMessage);
    }

    private String getBodyAsString(InputStream bodyStream) throws IOException {
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        byte[] chunk = new byte[64];
        int len;
        while ((len = bodyStream.read(chunk)) != -1) {
            buffer.write(chunk, 0, len);
        }
        return buffer.toString(StandardCharsets.US_ASCII);
    }

    @Override
    protected void writeInternal(String stringObjectMap, Type type, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
        throw new UnsupportedOperationException();
    }

}
ws51t4hk

ws51t4hk2#

谢谢@Codo。你救了我一天。虽然我没有使用JwtHttpMessageConverter类。我在SecurityConfig类中添加了以下内容。在我的情况下,我必须使用JwksUri和SignatureAlgorithm.RS512。

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
     
    http.
    authorizeRequests().
    anyRequest().authenticated().
    and().
    oauth2Login().userInfoEndpoint().oidcUserService(oidcUserService());

    return http.build();    
}

OidcUserService oidcUserService() {
    logger.info("OidcUserService bean");
    OidcUserService userService = new OidcUserService();
    //userService.setOauth2UserService(new ExampleOAuth2UserService(jwtDecoderUsingClientSecret("web-client")));
    userService.setOauth2UserService(new ExampleOAuth2UserService(jwtDecoder()));
    return userService;
}    

public JwtDecoder jwtDecoder() {
    String jwksUri =  Config.getJwksUri();
    System.out.println("jwtDecoder jwksUri="+jwksUri);
    return NimbusJwtDecoder.withJwkSetUri(jwksUri).jwsAlgorithm(SignatureAlgorithm.RS512).build();
}

公司名称:OAuth2 UserService发布时间:2009 - 4 - 11

private JwtDecoder jwtDecoder;

public ExampleAuth2UserService(JwtDecoder jwtDecoder) {
    this.jwtDecoder = jwtDecoder;
    RestTemplate restTemplate = new RestTemplate();
    restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
    this.restOperations = restTemplate;
}

相关问题