У меня есть пул пользователей 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, я думаю, мой вопрос больше сосредоточен на том, как мне преобразовать JWT, который я получаю, в более многофункциональный пользовательский объект, используя конечную точку oauth2/userInfo
лучше, чем просто вызывая его случайным образом по всей моей бизнес-логике, когда это необходимо
Вот почему я говорю о передаче токена ID в дополнение к токену доступа: другие решения будут включать вызов от сервера (ов) ресурсов к серверу авторизации для каждого запроса (или для запуска в кеш-ад) . Но я понятия не имею (пока?), как интегрировать парсинг такой пары токенов в spring-security. я напишу ответ, если найду способ...
У меня есть довольно простое (но не совсем эффективное) решение: настройте свой ресурс-сервер с самоанализом (opaqueToken
), а не с декодером JWT, используя oauth2/userInfo
в качестве конечной точки самоанализа и токен доступа JWT как «непрозрачный» токен. Это сделает то, что вы просите, но мы должны найти более эффективный способ декодирования JWT (доступ и идентификатор).
Хорошо, я нашел кое-что гораздо более эффективное (я этим очень горжусь ;). Подробности ниже.
Похоже, 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-boot-starter-oauth2-resource-server
. Он очень тонкий и с открытым исходным кодом. Если вы не хотите его использовать, посмотрите, как это делается, чтобы получить от него вдохновение:
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 ответ. Именно то, что я искал.
Рад, что это помогает. Я потратил некоторое время, чтобы собрать его воедино, но решение достаточно общее, чтобы служить во многих случаях использования, и я уверен, что буду рад иметь работающее решение для добавления случайных значений заголовков к аутентификациям в один прекрасный день или другой .
Да, я уже начал собирать его в общей библиотеке «повторно использовать где угодно». Хорошая работа, товарищ, еще раз очень ценю это.
Что касается повторного использования, пожалуйста, найдите время, чтобы внимательно изучить редактирование, которое я добавил в конце сообщения. Я некоторое время работал над абстрагированием конфигурации серверов ресурсов (особенно удаляя привязку к особенностям серверов авторизации).
Похоже, у вас будут трудные времена, потому что у Cognito, похоже, есть функции для обогащения только токенов ID: github.com/aws-amplify/amplify-js/issues/4015. С некоторыми другими серверами авторизации OIDC (например, Keycloak и Auth0) обогащение токенов доступа является простой задачей, но здесь, возможно, вам придется требовать токен ID в теле запроса или в выделенном заголовке (в дополнение к токен в заголовке авторизации), но у меня нет под рукой примера реализации :/