【问题标题】:Spring fails for userinfo endpoint returning signed JWT对于返回签名 JWT 的 userinfo 端点,Spring 失败
【发布时间】:2020-05-09 14:23:46
【问题描述】:

我们正在开发一个作为 OIDC 客户端的 Spring Boot 应用程序。身份提供者 (IdP) 是第三方服务,完全符合 OpenID Connect 和 OAuth 2.0(据我们所知)。由于它在构建时考虑到了高安全性,因此其 UserInfo 端点返回一个 已签名 JWT(而不是常规 JWT)。

Spring Security 好像不支持。身份验证流程以错误消息结束(显示在我们的 Spring 应用程序生成的 HTML 页面中):

[invalid_user_info_response] 尝试 检索 UserInfo 资源:无法提取响应:否 为响应类型找到合适的 HttpMessageConverter [java.util.Map] 和内容类型 [application/jwt;charset=UTF-8]

我的问题:

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

我们的分析表明,DefaultOAuth2UserService 请求 (Accept: application/json) 并期望来自 IdP 的 JSON 响应。但是,经过配置以实现高安全性,IdP 返回一个内容类型为 application/jwt 的签名 JWT。响应类似于jwt.io 上的示例。由于RestTemplate 没有能够处理内容类型application/jwt 的消息转换器,因此身份验证失败。

我们的示例应用程序非常简单:

build.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'
}

DemoApplication.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);
    }
}

application.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

HomeController.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"; }
}

【问题讨论】:

    标签: spring-security oauth-2.0 jwt openid-connect spring-security-oauth2


    【解决方案1】:

    经过更多分析,Spring Boot 似乎不支持返回签名 JWT 的 UserInfo 端点。这显然是一个不寻常的设置(但仍在 OAuth 2.0 / OIDC 规范内)。到目前为止我还没有提到的是 JWT 是用 client secret 签名的。

    虽然 Spring 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();
        }
    
    }
    

    【讨论】:

      猜你喜欢
      • 2018-04-30
      • 1970-01-01
      • 2017-09-13
      • 2016-06-12
      • 1970-01-01
      • 2018-10-02
      • 2023-02-01
      • 2017-02-12
      • 2016-10-21
      相关资源
      最近更新 更多