Сбой Spring для конечной точки userinfo, возвращающей подписанный JWT - PullRequest
0 голосов
/ 23 января 2020

Мы работаем над приложением Spring Boot, которое является клиентом OID C. Поставщик удостоверений (IdP) - это сторонняя служба, полностью совместимая с OpenID Connect и OAuth 2.0 (насколько мы можем судить). Поскольку он построен с учетом высокой степени безопасности, его конечная точка UserInfo возвращает подписанный JWT (вместо обычного).

Кажется, что Spring Security не поддерживает Это. Процесс аутентификации заканчивается сообщением об ошибке (отображается на странице HTML, созданной нашим приложением Spring):

[invalid_user_info_response] Произошла ошибка при попытке получить ресурс UserInfo: Не удалось извлечь ответ : не найден подходящий HttpMessageConverter для типа ответа [java .util.Map] и типа контента [application / jwt; charset = UTF-8]

Мои вопросы:

  • Правильно ли, что Spring в настоящее время не поддерживает UserInfo конечные точки, возвращающие подписанные JWT?
  • Если так, как мы можем добавить поддержку подписанных JWT (включая проверку подписи)?

Наш анализ показал, что DefaultOAuth2UserService запрашивает (Accept: application/json) и ожидает JSON ответа от IdP. Однако, будучи настроен на высокий уровень безопасности, IdP возвращает подписанный JWT с типом контента application/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"; }
}

1 Ответ

0 голосов
/ 27 января 2020

После дополнительного анализа кажется, что Spring Boot не поддерживает UserInfo конечные точки, возвращающие подписанные JWT. Это, очевидно, необычная настройка (но все еще в пределах спецификации OAuth 2.0 / OID C). До сих пор я не упомянул, что JWT подписан с секретом клиента .

Хотя Spring Boot его не поддерживает, его можно добавить. Решение состоит из:

  • Службы пользователя, поддерживающей подписанные JWT (в качестве замены для DefaultOAuth2UserService)
  • A HttpMessageConverter, поддерживающей JWT (используемой в RestTemplate пользовательской службы) )
  • A JwtDecoder с использованием секрета клиента
  • Конфигурация безопасности, объединяющая части

Обратите внимание, что мы изменили с OAuth 2.0 на OID C в настоящее время, таким образом, наша 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 OID C (т.е. вы не используете область действия '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();
    }

}
...