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 .