Реактивный ресурс-сервер Spring OAuth2: включите утверждения токенов доступа и идентификаторов в «Аутентификацию»

У меня есть пул пользователей AWS Cognito, выдающий токены моему интерфейсному приложению. Затем внешнее приложение использует токены для связи с моей серверной службой.

Этот поток работает по назначению. Я проверяю токены, попадающие в мою серверную службу, с помощью org.springframework.security:spring-security-oauth2-resource-server:6.0.1, настроенного так, чтобы он указывал на Cognito.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://cognito-idp.us-east-1.amazonaws.com/my_pool_endpoint

У меня простой SecurityConfig

@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity(useAuthorizationManager = true)
public class SecurityConfig {

    @Bean
    SecurityWebFilterChain securityWebFilterChain(final ServerHttpSecurity http) {

        return http.authorizeExchange()
                .pathMatchers("/v3/api-docs/**")
                .permitAll()
                .anyExchange()
                .authenticated()
                .and()
                .oauth2ResourceServer(ServerHttpSecurity.OAuth2ResourceServerSpec::jwt)
                .build();
    }

Пока все выглядит хорошо.

Но как мне получить дополнительную информацию из входящих токенов, например, такие вещи, как электронная почта и имя пользователя, не включаются в ответ токена от Cognito. Пример декодированного токена выглядит так:

{
  "sub": "00000000000000000000",
  "cognito:groups": [
    "00000000000000000000"
  ],
  "iss": "https://cognito-idp.us-east-1.amazonaws.com/00000000000000000000",
  "version": 2,
  "client_id": "00000000000000000000",
  "origin_jti": "00000000000000000000",
  "token_use": "access",
  "scope": "openid profile email",
  "auth_time": 1676066347,
  "exp": 1676186814,
  "iat": 1676143614,
  "jti": "00000000000000000000",
  "username": "google_00000000000000000000"
}

Когда мне нужна дополнительная информация от токена, я звоню https://my-congito-pool.auth.us-east-1.amazoncognito.com/oauth2/userInfo и передаю JWT в качестве токена Bearer, который работает и возвращает информацию, которую я ищу, такую ​​как электронная почта, изображение, имя пользователя и т. д.

Мой вопрос в том, что я не думаю, что делать это вручную каждый раз, когда мне нужна дополнительная информация, - это «правильный» способ ее обработки.

Должен ли я использовать что-то вроде UserDetailsService, чтобы выполнить это один раз и преобразовать входящий JWT в мой собственный User, который содержит эту информацию?

Если да, то как мне это сделать с помощью ReactiveSpringSecurity?

Похоже, у вас будут трудные времена, потому что у Cognito, похоже, есть функции для обогащения только токенов ID: github.com/aws-amplify/amplify-js/issues/4015. С некоторыми другими серверами авторизации OIDC (например, Keycloak и Auth0) обогащение токенов доступа является простой задачей, но здесь, возможно, вам придется требовать токен ID в теле запроса или в выделенном заголовке (в дополнение к токен в заголовке авторизации), но у меня нет под рукой примера реализации :/

ch4mp 11.02.2023 21:39

Да, я знаю об этой «особенности» Cognito, я думаю, мой вопрос больше сосредоточен на том, как мне преобразовать JWT, который я получаю, в более многофункциональный пользовательский объект, используя конечную точку oauth2/userInfo лучше, чем просто вызывая его случайным образом по всей моей бизнес-логике, когда это необходимо

Chris 11.02.2023 21:53

Вот почему я говорю о передаче токена ID в дополнение к токену доступа: другие решения будут включать вызов от сервера (ов) ресурсов к серверу авторизации для каждого запроса (или для запуска в кеш-ад) . Но я понятия не имею (пока?), как интегрировать парсинг такой пары токенов в spring-security. я напишу ответ, если найду способ...

ch4mp 11.02.2023 22:04

У меня есть довольно простое (но не совсем эффективное) решение: настройте свой ресурс-сервер с самоанализом (opaqueToken), а не с декодером JWT, используя oauth2/userInfo в качестве конечной точки самоанализа и токен доступа JWT как «непрозрачный» токен. Это сделает то, что вы просите, но мы должны найти более эффективный способ декодирования JWT (доступ и идентификатор).

ch4mp 11.02.2023 22:15

Хорошо, я нашел кое-что гораздо более эффективное (я этим очень горжусь ;). Подробности ниже.

ch4mp 12.02.2023 02:06
1
5
179
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Похоже, Cognito позволяет обогащать токены ID, но не токены доступа. Это печально, большинство конкурентов разрешают это, и это значительно упрощает настройку весенних серверов ресурсов.

Я могу придумать два решения:

  • настройте свой сервер ресурсов с самоанализом токена доступа (с http.oauth2ResourceServer().opaqueToken()), используя ваш /oauth2/userInfo в качестве конечной точки самоанализа и токен доступа JWT в качестве «непрозрачного» токена
  • требовать от клиентов добавления токена идентификатора в выделенный заголовок (скажем, X-ID-Token) в дополнение к токену доступа (как обычно, указанному в заголовке авторизации). Затем в конвертере проверки подлинности извлеките и декодируйте этот дополнительный заголовок и создайте собственную проверку подлинности с помощью строк и утверждений токенов доступа и идентификаторов.

Я буду разрабатывать только второе решение по двум причинам:

  • первый имеет обычную стоимость производительности самоанализа токена (вызов выполняется с сервера ресурсов на сервер авторизации перед обработкой каждого запроса)
  • второй позволяет добавлять любые данные из стольких заголовков, сколько нам нужно, в экземпляр Authentication для аутентификации и авторизации (не только токен ID, как мы демонстрируем здесь) с очень небольшим влиянием на производительность.

Спойлер: вот что у меня получилось:

  • с действительным доступом и идентификационными токенами
  • только с токеном доступа

Разве это не именно то, что вы ищете: экземпляр Authentication с ролями из токена доступа и электронной почты из токена ID (или Unauthorized, если данные авторизации отсутствуют/недействительны/неполные)?

Подробная конфигурация безопасности

Вот конфигурация безопасности для реактивного приложения. Для сервлетов основные линии одинаковы, только инструменты для статического доступа к контексту запроса сильно отличаются. Вы можете обратиться к этому уроку Я только что добавил в свою коллекцию для деталей.

@Configuration
@EnableReactiveMethodSecurity
@EnableWebFluxSecurity
public class SecurityConfig {
    static final String ID_TOKEN_HEADER_NAME = "X-ID-Token";

    public static Mono<ServerHttpRequest> getServerHttpRequest() {
        return Mono.deferContextual(Mono::just)
                .map(contextView -> contextView.get(ServerWebExchange.class).getRequest());
    }

    public static Mono<String> getIdTokenHeader() {
        return getServerHttpRequest().map(req -> {
            final var headers = req.getHeaders().getOrEmpty(ID_TOKEN_HEADER_NAME).stream()
                    .filter(StringUtils::hasLength).toList();
            if (headers.size() == 0) {
                throw new MissingIdTokenException();
            }
            if (headers.size() > 1) {
                throw new MultiValuedIdTokenException();
            }
            return headers.get(0);
        });
    }

    @Bean
    SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http, ReactiveJwtDecoder jwtDecoder) {
        http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(accessToken -> getIdTokenHeader()
                .flatMap(idTokenString -> jwtDecoder.decode(idTokenString).doOnError(JwtException.class, e -> {
                    throw new InvalidIdTokenException();
                }).map(idToken -> {
                    final var idClaims = idToken.getClaims();

                    @SuppressWarnings("unchecked")
                    final var authorities = ((List<String>) accessToken.getClaims().getOrDefault("cognito:groups",
                            List.of())).stream().map(SimpleGrantedAuthority::new).toList();

                    return new MyAuth(authorities, accessToken.getTokenValue(), idTokenString, accessToken.getClaims(),
                            idClaims);
                })));

        http.securityContextRepository(NoOpServerSecurityContextRepository.getInstance()).csrf().disable();

        http.authorizeExchange().anyExchange().authenticated();

        return http.build();
    }

    public static class MyAuth extends AbstractAuthenticationToken {
        private static final long serialVersionUID = 9115947200114995708L;

        // Save access and ID tokens strings just in case we need to call another
        // micro-service on behalf of the user who initiated the request and as so,
        // position "Authorization" and "X-ID-Token" headers
        private final String accessTokenString;
        private final String idTokenString;

        private final Map<String, Object> accessClaims;
        private final Map<String, Object> idClaims;

        public MyAuth(Collection<? extends GrantedAuthority> authorities, String accessTokenString,
                String idTokenString, Map<String, Object> accessClaims, Map<String, Object> idClaims) {
            super(authorities);
            this.accessTokenString = accessTokenString;
            this.accessClaims = Collections.unmodifiableMap(accessClaims);
            this.idTokenString = idTokenString;
            this.idClaims = Collections.unmodifiableMap(idClaims);

            // Minimal security checks: assert that issuer and subject claims are the same
            // in access and ID tokens.
            if (!Objects.equals(accessClaims.get(IdTokenClaimNames.ISS), idClaims.get(IdTokenClaimNames.ISS))
                    || !Objects.equals(accessClaims.get(StandardClaimNames.SUB), idClaims.get(IdTokenClaimNames.SUB))) {
                throw new InvalidIdTokenException();
            }
            // You could also make assertions on ID token audience, but this will require
            // adding a custom property for expected ID tokens audience.
            // You can't just check for audience equality with already validated access
            // token one.

            this.setAuthenticated(true);
        }

        @Override
        public String getCredentials() {
            return accessTokenString;
        }

        @Override
        public String getPrincipal() {
            return (String) accessClaims.get(StandardClaimNames.SUB);
        }

        public String getAccessTokenString() {
            return accessTokenString;
        }

        public String getIdTokenString() {
            return idTokenString;
        }

        public Map<String, Object> getAccessClaims() {
            return accessClaims;
        }

        public Map<String, Object> getIdClaims() {
            return idClaims;
        }

    }

    @ResponseStatus(code = HttpStatus.UNAUTHORIZED, reason = ID_TOKEN_HEADER_NAME + " is missing")
    static class MissingIdTokenException extends RuntimeException {
        private static final long serialVersionUID = -4894061353773464761L;
    }

    @ResponseStatus(code = HttpStatus.UNAUTHORIZED, reason = ID_TOKEN_HEADER_NAME + " is not unique")
    static class MultiValuedIdTokenException extends RuntimeException {
        private static final long serialVersionUID = 1654993007508549674L;
    }

    @ResponseStatus(code = HttpStatus.UNAUTHORIZED, reason = ID_TOKEN_HEADER_NAME + " is not valid")
    static class InvalidIdTokenException extends RuntimeException {
        private static final long serialVersionUID = -6233252290377524340L;
    }
}

Теперь каждый раз, когда авторизация завершается успешно (isAuthenticated() – это true), у вас будет экземпляр MyAuth в контексте безопасности, содержащий как токены доступа, так и токены идентификатора!

Контроллер выборки

@RestController
public class GreetingController {
    
    @GetMapping("/greet")
    @PreAuthorize("isAuthenticated()")
    Mono<String> greet(MyAuth auth) {
        return Mono.just("Hello %s! You are granted with %s".formatted(
            auth.getIdClaims().get("email"),
            auth.getAuthorities()));
    }

}

Вы также можете создавать свои @PreAuthorize выражения на его основе. Что-то вроде:

@RequiredArgsConstructor
@RestController
@RequestMapping("/something/protected")
@PreAuthorize("isAuthenticated()")
public class ProtectedResourceController {
    private final SomeResourceRepository resourceRepo;

    @GetMapping("/{resourceId}")
    @PreAuthorize("#auth.idClaims['email'] == #resource.email")
    ResourceDto getProtectedResource(MyAuth auth, @RequestParam("resourceId") SomeResource resource) {
        ...
    }

}

Обновлено: повторное использование кода и стартеры spring-addons

Я поддерживаю обертки вокруг spring-boot-starter-oauth2-resource-server. Он очень тонкий и с открытым исходным кодом. Если вы не хотите его использовать, посмотрите, как это делается, чтобы получить от него вдохновение:

  • изучите ресурсы, чтобы узнать, что нужно для создания собственных стартеров Spring-Boot
  • проверять bean-компоненты, чтобы выбрать идеи для создания собственных конфигурируемых
  • перейдите к зависимостям, таким как OpenidClaimSet и OAuthentication, которые могут быть источником вдохновения

Вот что приведенный выше пример становится с «моим» стартером для реактивных серверов ресурсов с декодерами JWT:

@Configuration
@EnableReactiveMethodSecurity
@EnableWebFluxSecurity
public class SecurityConfig {
    static final String ID_TOKEN_HEADER_NAME = "X-ID-Token";

    @Bean
    OAuth2AuthenticationFactory authenticationFactory(
            Converter<Map<String, Object>, Collection<? extends GrantedAuthority>> authoritiesConverter,
            ReactiveJwtDecoder jwtDecoder) {
        return (accessBearerString, accessClaims) -> ServerHttpRequestSupport.getUniqueHeader(ID_TOKEN_HEADER_NAME)
                .flatMap(idTokenString -> jwtDecoder.decode(idTokenString).doOnError(JwtException.class, e -> {
                    throw new InvalidHeaderException(ID_TOKEN_HEADER_NAME);
                }).map(idToken -> new MyAuth(
                        authoritiesConverter.convert(accessClaims),
                        accessBearerString,
                        new OpenidClaimSet(accessClaims),
                        idTokenString,
                        new OpenidClaimSet(idToken.getClaims()))));
    }

    @Data
    @EqualsAndHashCode(callSuper = true)
    public static class MyAuth extends OAuthentication<OpenidClaimSet> {
        private static final long serialVersionUID = 1734079415899000362L;
        private final String idTokenString;
        private final OpenidClaimSet idClaims;

        public MyAuth(Collection<? extends GrantedAuthority> authorities, String accessTokenString,
                OpenidClaimSet accessClaims, String idTokenString, OpenidClaimSet idClaims) {
            super(accessClaims, authorities, accessTokenString);
            this.idTokenString = idTokenString;
            this.idClaims = idClaims;
        }

    }
}

Обновите @Controller (обратите внимание на прямой доступ к претензии email):

@RestController
public class GreetingController {
    
    @GetMapping("/greet")
    @PreAuthorize("isAuthenticated()")
    Mono<String> greet(MyAuth auth) {
        return Mono.just("Hello %s! You are granted with %s".formatted(
                auth.getIdClaims().getEmail(),
                auth.getAuthorities()));
    }
}

Это свойства конфигурации (с разными утверждениями, используемыми в качестве источника полномочий, в зависимости от сервера авторизации, настроенного в профиле):

server:
  error.include-message: always

spring:
  lifecycle.timeout-per-shutdown-phase: 30s
  security.oauth2.resourceserver.jwt.issuer-uri: https://localhost:8443/realms/master

com:
  c4-soft:
    springaddons:
      security:
        issuers:
          - location: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}
            authorities:
              claims:
                - realm_access.roles
                - resource_access.spring-addons-public.roles
                - resource_access.spring-addons-confidential.roles
              caze: upper
              prefix: ROLE_
        cors:
          - path: /greet
          

---
spring.config.activate.on-profile: cognito
spring.security.oauth2.resourceserver.jwt.issuer-uri: https://cognito-idp.us-west-2.amazonaws.com/us-west-2_RzhmgLwjl
com.c4-soft.springaddons.security.issuers:
  - location: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}
    authorities:
      claims: 
        - cognito:groups
      caze: upper
      prefix: ROLE_

---
spring.config.activate.on-profile: auth0
com.c4-soft.springaddons.security.issuers:
  - location: https://dev-ch4mpy.eu.auth0.com/
    authorities:
      claims:
        - roles
        - permissions
      caze: upper
      prefix: ROLE_

Юнит-тесты с фиктивной идентификацией для @Controller выше могут быть такими простыми, как:

@WebFluxTest(controllers = GreetingController.class)
@AutoConfigureAddonsWebSecurity
@Import(SecurityConfig.class)
class GreetingControllerTest {

    @Autowired
    WebTestClientSupport api;

    @Test
    @WithMyAuth(authorities = { "AUTHOR" }, idClaims = @OpenIdClaims(email = "[email protected]"))
    void givenUserIsAuthenticated_whenGreet_thenOk() throws Exception {
        api.get("/greet").expectStatus().isOk()
                .expectBody(String.class).isEqualTo("Hello [email protected]! You are granted with [AUTHOR]");
    }

    @Test
    void givenRequestIsAnonymous_whenGreet_thenUnauthorized() throws Exception {
        api.get("/greet").expectStatus().isUnauthorized();
    }

}

С определением аннотации (чтобы создать пользовательскую реализацию Authentication и установить ее в контексте безопасности):

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@WithSecurityContext(factory = WithMyAuth.MyAuthFactory.class)
public @interface WithMyAuth {

    @AliasFor("authorities")
    String[] value() default {};

    @AliasFor("value")
    String[] authorities() default {};

    OpenIdClaims accessClaims() default @OpenIdClaims();

    OpenIdClaims idClaims() default @OpenIdClaims();

    String accessTokenString() default "machin.truc.chose";

    String idTokenString() default "machin.bidule.chose";

    @AliasFor(annotation = WithSecurityContext.class)
    TestExecutionEvent setupBefore()

    default TestExecutionEvent.TEST_METHOD;

    @Target({ ElementType.METHOD, ElementType.TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    public static @interface Proxy {
        String onBehalfOf();

        String[] can() default {};
    }

    public static final class MyAuthFactory extends AbstractAnnotatedAuthenticationBuilder<WithMyAuth, MyAuth> {
        @Override
        public MyAuth authentication(WithMyAuth annotation) {
            final var accessClaims = new OpenidClaimSet(super.claims(annotation.accessClaims()));
            final var idClaims = new OpenidClaimSet(super.claims(annotation.idClaims()));

            return new MyAuth(super.authorities(annotation.authorities()), annotation.accessTokenString(), accessClaims, annotation.idTokenString(), idClaims);
        }
    }
}

А это корпус помпона:

    <properties>
        <java.version>17</java.version>
        <spring-addons.version>6.0.13</spring-addons.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>com.c4-soft.springaddons</groupId>
            <artifactId>spring-addons-webflux-jwt-resource-server</artifactId>
            <version>${spring-addons.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.c4-soft.springaddons</groupId>
            <artifactId>spring-addons-webflux-jwt-test</artifactId>
            <version>${spring-addons.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

7/7 ответ. Именно то, что я искал.

Chris 12.02.2023 17:56

Рад, что это помогает. Я потратил некоторое время, чтобы собрать его воедино, но решение достаточно общее, чтобы служить во многих случаях использования, и я уверен, что буду рад иметь работающее решение для добавления случайных значений заголовков к аутентификациям в один прекрасный день или другой .

ch4mp 12.02.2023 18:28

Да, я уже начал собирать его в общей библиотеке «повторно использовать где угодно». Хорошая работа, товарищ, еще раз очень ценю это.

Chris 12.02.2023 18:40

Что касается повторного использования, пожалуйста, найдите время, чтобы внимательно изучить редактирование, которое я добавил в конце сообщения. Я некоторое время работал над абстрагированием конфигурации серверов ресурсов (особенно удаляя привязку к особенностям серверов авторизации).

ch4mp 12.02.2023 23:08

Другие вопросы по теме