Перейти к основному содержимому

Spring Security — авторитетные карты от JWT

· 10 мин. чтения

1. Введение

В этом руководстве мы покажем, как настроить сопоставление утверждений JWT (веб-токен JSON) с авторитетами Spring Security .

2. Фон

Когда правильно настроенное приложение на основе Spring Security получает запрос, оно проходит ряд шагов, которые, по сути, преследуют две цели:

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

Для приложения, использующего JWT в качестве основного механизма безопасности , аспект авторизации состоит из:

  • Извлечение значений утверждений из полезной нагрузки JWT, обычно это утверждение области или объекта scp .
  • Сопоставление этих утверждений с набором объектов GrantedAuthority

После того как подсистема безопасности установила эти полномочия, она может оценить, применяются ли какие-либо ограничения доступа к текущему запросу, и решить, может ли он продолжаться .

3. Сопоставление по умолчанию

По умолчанию Spring использует простую стратегию для преобразования утверждений в экземпляры GrantedAuthority . Во-первых, он извлекает область действия или утверждение scp и разбивает его на список строк. Затем для каждой строки создается новый SimpleGrantedAuthority с использованием префикса SCOPE_ , за которым следует значение области действия.

Чтобы проиллюстрировать эту стратегию, давайте создадим простую конечную точку, которая позволит нам проверять некоторые ключевые свойства экземпляра Authentication , доступного приложению:

@RestController
@RequestMapping("/user")
public class UserRestController {

@GetMapping("/authorities")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {

Collection<String> authorities = principal.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());

Map<String,Object> info = new HashMap<>();
info.put("name", principal.getName());
info.put("authorities", authorities);
info.put("tokenAttributes", principal.getTokenAttributes());

return info;
}
}

Здесь мы используем аргумент JwtAuthenticationToken , поскольку знаем, что при использовании аутентификации на основе JWT это будет фактическая реализация аутентификации , созданная Spring Security. Мы создаем результат, извлекая его из свойства name , доступных экземпляров GrantedAuthority и исходных атрибутов JWT.

Теперь давайте предположим, что мы вызываем эту передачу конечной точки и закодированный и подписанный JWT, содержащий эту полезную нагрузку:

{
"aud": "api://f84f66ca-591f-4504-960a-3abc21006b45",
"iss": "https://sts.windows.net/2e9fde3a-38ec-44f9-8bcd-c184dc1e8033/",
"iat": 1648512013,
"nbf": 1648512013,
"exp": 1648516868,
"email": "psevestre@gmail.com",
"family_name": "Sevestre",
"given_name": "Philippe",
"name": "Philippe Sevestre",
"scp": "profile.read",
"sub": "eXWysuqIJmK1yDywH3gArS98PVO1SV67BLt-dvmQ-pM",
... more claims omitted
}

Ответ должен выглядеть как объект JSON с тремя свойствами:

{
"tokenAttributes": {
// ... token claims omitted
},
"name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
"authorities": [
"SCOPE_profile",
"SCOPE_email",
"SCOPE_openid"
]
}

Мы можем использовать эти области для ограничения доступа к определенным частям наших приложений, создав SecurityFilterChain :

@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
return http.authorizeRequests(auth -> {
auth.antMatchers("/user/**")
.hasAuthority("SCOPE_profile");
})
.build();
}

Обратите внимание, что мы намеренно избегаем использования WebSecurityConfigureAdapter . Как описано , этот класс будет объявлен устаревшим в версии Spring Security 5.7, поэтому лучше начать переход на новый подход как можно раньше .

В качестве альтернативы мы могли бы использовать аннотации уровня метода и выражение SpEL для достижения того же результата:

@GetMapping("/authorities")
@PreAuthorize("hasAuthority('SCOPE_profile.read')")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
// ... same code as before
}

Наконец, для более сложных сценариев мы также можем прибегнуть к прямому доступу к текущему JwtAuthenticationToken, из которого у нас есть прямой доступ ко всем GrantedAuthorities.

4. Настройка префикса SCOPE_

В качестве нашего первого примера того, как изменить поведение сопоставления утверждений Spring Security по умолчанию, давайте посмотрим, как изменить префикс SCOPE_ на что-то другое. Как описано в документации, в этой задаче участвуют два класса:

  • JwtAuthenticationConverter : преобразует необработанный JWT в AbstractAuthenticationToken .
  • JwtGrantedAuthoritiesConverter : извлекает коллекцию экземпляров GrantedAuthority из необработанного JWT.

Внутри JwtAuthenticationConverter использует JwtGrantedAuthoritiesConverter для заполнения JwtAuthenticationToken объектами GrantedAuthority наряду с другими атрибутами.

Самый простой способ изменить этот префикс — предоставить наш собственный bean- компонент JwtAuthenticationConverter , настроенный с помощью JwtGrantedAuthoritiesConverter , настроенного на один из наших собственных вариантов:

@Configuration
@EnableConfigurationProperties(JwtMappingProperties.class)
@EnableMethodSecurity
public class SecurityConfig {
// ... fields and constructor omitted
@Bean
public Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
if (StringUtils.hasText(mappingProps.getAuthoritiesPrefix())) {
converter.setAuthorityPrefix(mappingProps.getAuthoritiesPrefix().trim());
}
return converter;
}

@Bean
public JwtAuthenticationConverter customJwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter();
return converter;
}

Здесь JwtMappingProperties — это просто класс @ConfigurationProperties , который мы будем использовать для экстернализации свойств сопоставления. Хотя это не показано в этом фрагменте кода, мы будем использовать внедрение конструктора для инициализации поля mappingProps экземпляром, заполненным из любого настроенного PropertySource , что дает нам достаточную гибкость для изменения его значений во время развертывания.

Этот класс @Configuration имеет два метода @Bean : jwtGrantedAuthoritiesConverter() создает необходимый преобразователь , который создает коллекцию GrantedAuthority . В этом случае мы используем стандартный JwtGrantedAuthoritiesConverter , настроенный с префиксом, установленным в свойствах конфигурации.

Затем у нас есть customJwtAuthenticationConverter() , где мы создаем JwtAuthenticationConverter , настроенный для использования нашего пользовательского конвертера. Оттуда Spring Security подберет его как часть стандартного процесса автоматической настройки и заменит значение по умолчанию.

Теперь, как только мы установим для свойства foreach.jwt.mapping.authorities-prefix какое-либо значение, например, MY_SCOPE , и вызовем /user/authorities, мы увидим настроенные полномочия:

{
"tokenAttributes": {
// ... token claims omitted
},
"name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
"authorities": [
"MY_SCOPE_profile",
"MY_SCOPE_email",
"MY_SCOPE_openid"
]
}

5. Использование настраиваемого префикса в конструкциях безопасности

Важно отметить, что, изменив префиксы органов власти, мы повлияем на любое правило авторизации, основанное на их именах. Например, если мы изменим префикс на MY_PREFIX_ , любые выражения @PreAuthorize , которые предполагают префикс по умолчанию, больше не будут работать. То же самое относится к конструкциям авторизации на основе HttpSecurity .

Однако решить эту проблему просто. Во-первых, давайте добавим в наш класс @Configuration метод @Bean , возвращающий настроенный префикс. Поскольку эта конфигурация не является обязательной, мы должны убедиться, что возвращаем значение по умолчанию, если оно никому не было дано:

@Bean
public String jwtGrantedAuthoritiesPrefix() {
return mappingProps.getAuthoritiesPrefix() != null ?
mappingProps.getAuthoritiesPrefix() :
"SCOPE_";
}

Теперь мы можем использовать ссылку на этот bean-компонент, используя синтаксис @<bean-name> в выражениях SpEL . Вот как мы будем использовать bean-компонент префикса с @PreAuthorize :

@GetMapping("/authorities")
@PreAuthorize("hasAuthority(@jwtGrantedAuthoritiesPrefix + 'profile.read')")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
// ... method implementation omitted
}

Мы также можем использовать аналогичный подход при определении SecurityFilterChain :

@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
return http.authorizeRequests(auth -> {
auth.antMatchers("/user/**")
.hasAuthority(mappingProps.getAuthoritiesPrefix() + "profile");
})
// ... other customizations omitted
.build();
}

6. Настройка имени принципала

Иногда стандартное дополнительное утверждение о том, что Spring сопоставляется со свойством имени аутентификации, имеет значение, которое не очень полезно. Хорошим примером являются JWT, сгенерированные Keycloak: ``

{
// ... other claims omitted
"sub": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
"scope": "openid profile email",
"email_verified": true,
"name": "User Primo",
"preferred_username": "user1",
"given_name": "User",
"family_name": "Primo"
}

В этом случае sub поставляется с внутренним идентификатором, но мы видим, что утверждение selected_username имеет более понятное значение. Мы можем легко изменить поведение JwtAuthenticationConverter , установив его свойство PrincipalClaimName с желаемым именем утверждения :

@Bean
public JwtAuthenticationConverter customJwtAuthenticationConverter() {

JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter());

if (StringUtils.hasText(mappingProps.getPrincipalClaimName())) {
converter.setPrincipalClaimName(mappingProps.getPrincipalClaimName());
}
return converter;
}

Теперь, если мы установим для свойства foreach.jwt.mapping.authorities-prefix значение «preferred_username», результат /user/authorities изменится соответствующим образом:

{
"tokenAttributes": {
// ... token claims omitted
},
"name": "user1",
"authorities": [
"MY_SCOPE_profile",
"MY_SCOPE_email",
"MY_SCOPE_openid"
]
}

7. Сопоставление имен областей

Иногда нам может понадобиться сопоставить имена областей, полученные в JWT, с внутренним именем. Например, это может быть случай, когда одно и то же приложение должно работать с токенами, сгенерированными разными серверами авторизации, в зависимости от среды, в которой оно было развернуто.

У нас может возникнуть соблазн расширить JwtGrantedAuthoritiesConverter, но, поскольку это последний класс, мы не можем использовать этот подход. Вместо этого мы должны написать наш собственный класс Converter и внедрить его в JwtAuthorizationConverter . Этот улучшенный преобразователь, MappingJwtGrantedAuthoritiesConverter , реализует Converter<Jwt, Collection<GrantedAuthority>> и очень похож на исходный:

public class MappingJwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
private static Collection<String> WELL_KNOWN_AUTHORITIES_CLAIM_NAMES = Arrays.asList("scope", "scp");
private Map<String,String> scopes;
private String authoritiesClaimName = null;
private String authorityPrefix = "SCOPE_";

// ... constructor and setters omitted

@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {

Collection<String> tokenScopes = parseScopesClaim(jwt);
if (tokenScopes.isEmpty()) {
return Collections.emptyList();
}

return tokenScopes.stream()
.map(s -> scopes.getOrDefault(s, s))
.map(s -> this.authorityPrefix + s)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toCollection(HashSet::new));
}

protected Collection<String> parseScopesClaim(Jwt jwt) {
// ... parse logic omitted
}
}

Здесь ключевым аспектом этого класса является этап сопоставления, где мы используем предоставленную карту областей видимости для преобразования исходных областей видимости в сопоставленные . Кроме того, любая входящая область, не имеющая доступного сопоставления, будет сохранена.

Наконец, мы используем этот расширенный преобразователь в нашей @Configuration в его методе jwtGrantedAuthoritiesConverter() :

@Bean
public Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() {
MappingJwtGrantedAuthoritiesConverter converter = new MappingJwtGrantedAuthoritiesConverter(mappingProps.getScopes());

if (StringUtils.hasText(mappingProps.getAuthoritiesPrefix())) {
converter.setAuthorityPrefix(mappingProps.getAuthoritiesPrefix());
}
if (StringUtils.hasText(mappingProps.getAuthoritiesClaimName())) {
converter.setAuthoritiesClaimName(mappingProps.getAuthoritiesClaimName());
}
return converter;
}

8. Использование пользовательского конвертера JwtAuthenticationConverter

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

Существует два возможных подхода к замене стандартного JwtAuthenticationConverter . Первый, который мы использовали в предыдущих разделах, заключается в создании метода @Bean , который возвращает наш пользовательский преобразователь. Это, однако, подразумевает, что наша настроенная версия должна расширять JwtAuthenticationConverter Spring, чтобы процесс автоконфигурации мог выбрать ее.

Второй вариант — использовать DSL-подход на основе HttpSecurity , где мы можем предоставить собственный преобразователь. Мы сделаем это с помощью настройщика oauth2ResourceServer , который позволяет нам подключать любой преобразователь, реализующий гораздо более общий интерфейс Converter<Jwt, AbstractAuthorizationToken> :

@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
return http.oauth2ResourceServer(oauth2 -> {
oauth2.jwt()
.jwtAuthenticationConverter(customJwtAuthenticationConverter());
})
.build();
}

Наш CustomJwtAuthenticationConverter использует AccountService (доступный онлайн) для извлечения объекта Account на основе значения утверждения имени пользователя. Затем он использует его для создания CustomJwtAuthenticationToken с дополнительным методом доступа к данным учетной записи:

public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

// ...private fields and construtor omitted
@Override
public AbstractAuthenticationToken convert(Jwt source) {

Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(source);
String principalClaimValue = source.getClaimAsString(this.principalClaimName);
Account acc = accountService.findAccountByPrincipal(principalClaimValue);
return new AccountToken(source, authorities, principalClaimValue, acc);
}
}

Теперь давайте изменим наш обработчик /user/authorities , чтобы использовать нашу расширенную аутентификацию :

@GetMapping("/authorities")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {

// ... create result map as before (omitted)
if (principal instanceof AccountToken) {
info.put( "account", ((AccountToken)principal).getAccount());
}
return info;
}

Одним из преимуществ такого подхода является то, что теперь мы можем легко использовать наш объект расширенной аутентификации в других частях приложения . Например, мы можем получить доступ к информации об учетной записи в выражениях SpEL непосредственно из встроенной переменной аутентификации :

@GetMapping("/account/{accountNumber}")
@PreAuthorize("authentication.account.accountNumber == #accountNumber")
public Account getAccountById(@PathVariable("accountNumber") String accountNumber, AccountToken authentication) {
return authentication.getAccount();
}

Здесь выражение @PreAuthorize обеспечивает, чтобы номер accountNumber , переданный в переменной пути, принадлежал пользователю. Этот подход особенно полезен при использовании совместно с Spring Data JPA, как описано в официальной документации .

9. Советы по тестированию

Приведенные до сих пор примеры предполагают, что у нас есть работающий поставщик удостоверений (IdP), который выдает токены доступа на основе JWT. Хороший вариант — использовать встроенный сервер Keycloak, который мы уже рассмотрели здесь . Дополнительные инструкции по настройке также доступны в нашем кратком руководстве по использованию Keycloak .

Обратите внимание, что в этих инструкциях описано, как зарегистрировать клиент OAuth. Для живых тестов Postman — хороший инструмент, который поддерживает поток кода авторизации. Важной деталью здесь является то, как правильно настроить параметр Valid Redirect URI . Поскольку Postman — настольное приложение, оно использует вспомогательный сайт, расположенный по адресу https://oauth.pstmn.io/v1/callback , для захвата кода авторизации. Следовательно, мы должны убедиться, что у нас есть подключение к Интернету во время тестов. Если это невозможно, мы можем вместо этого использовать менее безопасный поток предоставления пароля.

Независимо от выбранного IdP и клиента, мы должны настроить наш сервер ресурсов, чтобы он мог правильно проверять полученные JWT . Для стандартных поставщиков OIDC это означает предоставление подходящего значения для свойства spring.security.oauth2.resourceserver.jwt.issuer-uri . Затем Spring извлечет все детали конфигурации, используя доступный там документ .well-known/openid-configuration .

В нашем случае URI издателя для нашей области Keycloak — http://localhost:8083/auth/realms/foreach. Мы можем указать нашему браузеру получить полный документ по адресу http://localhost:8083/auth/realms/foreach/.well-known/openid-configuration .

10. Заключение

В этой статье мы показали различные способы настройки того, как заявляют авторитеты карты Spring Security из JWT. Как обычно, полный код доступен на GitHub .