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

Использование JWT с Spring Security OAuth

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

1. Обзор

В этом руководстве мы обсудим, как заставить нашу реализацию Spring Security OAuth2 использовать веб-токены JSON.

Мы также продолжаем развивать статью Spring REST API + OAuth2 + Angular в этой серии OAuth.

2. Сервер авторизации OAuth2

Ранее стек Spring Security OAuth предлагал возможность настроить сервер авторизации в качестве приложения Spring. Затем нам пришлось настроить его для использования JwtTokenStore , чтобы мы могли использовать токены JWT.

Однако стек OAuth устарел в Spring, и теперь мы будем использовать Keycloak в качестве нашего сервера авторизации.

Итак, на этот раз мы настроим наш сервер авторизации как встроенный сервер Keycloak в приложении Spring Boot . Он выдает токены JWT по умолчанию, поэтому в этом отношении нет необходимости в какой-либо другой конфигурации.

3. Сервер ресурсов

Теперь давайте посмотрим, как настроить наш сервер ресурсов для использования JWT.

Мы сделаем это в файле application.yml :

server: 
port: 8081
servlet:
context-path: /resource-server

spring:
jpa:
defer-datasource-initialization: true
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8083/auth/realms/foreach
jwk-set-uri: http://localhost:8083/auth/realms/foreach/protocol/openid-connect/certs

JWT включают всю информацию в токене, поэтому серверу ресурсов необходимо проверить подпись токена, чтобы убедиться, что данные не были изменены. Свойство jwk-set-uri содержит открытый ключ , который сервер может использовать для этой цели .

Свойство issuer-uri указывает на базовый URI сервера авторизации, который также можно использовать для проверки утверждения iss в качестве дополнительной меры безопасности.

Кроме того, если свойство jwk-set-uri не задано, сервер ресурсов попытается использовать uri-адрес выпускающего для определения местоположения этого ключа из конечной точки метаданных сервера авторизации .

Важно отметить, что добавление свойства issuer-uri требует, чтобы у нас был запущен сервер авторизации, прежде чем мы сможем запустить приложение Resource Server .

Теперь давайте посмотрим, как мы можем настроить поддержку JWT, используя конфигурацию Java:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors()
.and()
.authorizeRequests()
.antMatchers(HttpMethod.GET, "/user/info", "/api/foos/**")
.hasAuthority("SCOPE_read")
.antMatchers(HttpMethod.POST, "/api/foos")
.hasAuthority("SCOPE_write")
.anyRequest()
.authenticated()
.and()
.oauth2ResourceServer()
.jwt();
}
}

Здесь мы переопределяем конфигурацию HTTP Security по умолчанию; нам нужно явно указать, что мы хотим, чтобы он вел себя как сервер ресурсов, и что мы будем использовать токены доступа в формате JWT с использованием методов oauth2ResourceServer() и jwt() соответственно .

Приведенная выше конфигурация JWT — это то, что нам предоставляет экземпляр Spring Boot по умолчанию. Это также можно настроить, как мы вскоре увидим.

4. Пользовательские утверждения в токене

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

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

4.1. Конфигурация сервера авторизации

Для этого нам нужно добавить пару конфигураций в наш файл определения области, foreach-realm.json :

  • Добавьте организацию атрибутов нашему пользователю john@test.com :
"attributes" : {
"organization" : "foreach"
},
  • Добавьте организацию под названием protocolMapper в конфигурацию jwtClient :
"protocolMappers": [{
"id": "06e5fc8f-3553-4c75-aef4-5a4d7bb6c0d1",
"name": "organization",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "organization",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "organization",
"jsonType.label": "String"
}
}],

Для автономной установки Keycloak это также можно сделать с помощью консоли администратора.

Важно помнить, что приведенная выше конфигурация JSON специфична для Keycloak и может отличаться для других серверов OAuth .

С этой новой конфигурацией мы получим дополнительный атрибут, организация = foreach , в полезной нагрузке токена для john@test.com :

{
jti: "989ce5b7-50b9-4cc6-bc71-8f04a639461e"
exp: 1585242462
nbf: 0
iat: 1585242162
iss: "http://localhost:8083/auth/realms/foreach"
sub: "a5461470-33eb-4b2d-82d4-b0484e96ad7f"
typ: "Bearer"
azp: "jwtClient"
auth_time: 1585242162
session_state: "384ca5cc-8342-429a-879c-c15329820006"
acr: "1"
scope: "profile write read"
organization: "foreach"
preferred_username: "john@test.com"
}

4.2. Используйте токен доступа в клиенте Angular

Далее мы хотим использовать информацию о токене в нашем клиентском приложении Angular. Для этого мы будем использовать библиотеку angular2-jwt .

Мы воспользуемся утверждением организации в нашем AppService и добавим функцию getOrganization :

getOrganization(){
var token = Cookie.get("access_token");
var payload = this.jwtHelper.decodeToken(token);
this.organization = payload.organization;
return this.organization;
}

Эта функция использует JwtHelperService из библиотеки angular2-jwt для декодирования токена доступа и получения нашего пользовательского утверждения. Теперь все, что нам нужно сделать, это отобразить его в нашем AppComponent :

@Component({
selector: 'app-root',
template: `<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="/">Spring Security Oauth - Authorization Code</a>
</div>
</div>
<div class="navbar-brand">
<p>{{organization}}</p>
</div>
</nav>
<router-outlet></router-outlet>`
})

export class AppComponent implements OnInit {
public organization = "";
constructor(private service: AppService) { }

ngOnInit() {
this.organization = this.service.getOrganization();
}
}

5. Доступ к дополнительным утверждениям на сервере ресурсов

Но как мы можем получить доступ к этой информации на стороне сервера ресурсов?

5.1. Доступ к утверждениям сервера аутентификации

Это действительно просто, нам просто нужно извлечь его из `` AuthenticationPrincipal org.springframework.security.oauth2.jwt.Jwt , как мы сделали бы для любого другого атрибута в UserInfoController :

@GetMapping("/user/info")
public Map<String, Object> getUserInfo(@AuthenticationPrincipal Jwt principal) {
Map<String, String> map = new Hashtable<String, String>();
map.put("user_name", principal.getClaimAsString("preferred_username"));
map.put("organization", principal.getClaimAsString("organization"));
return Collections.unmodifiableMap(map);
}

5.2. Конфигурация для добавления/удаления/переименования утверждений

А что, если мы хотим добавить больше утверждений на стороне сервера ресурсов? Или удалить или переименовать некоторые?

Допустим, мы хотим изменить утверждение организации , поступающее с сервера аутентификации, чтобы получить значение в верхнем регистре. Однако, если утверждение отсутствует у пользователя, нам нужно установить его значение как unknown .

Для этого нам нужно добавить класс, реализующий интерфейс Converter и использующий MappedJwtClaimSetConverter для преобразования утверждений :

public class OrganizationSubClaimAdapter implements 
Converter<Map<String, Object>, Map<String, Object>> {

private final MappedJwtClaimSetConverter delegate =
MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap());

public Map<String, Object> convert(Map<String, Object> claims) {
Map<String, Object> convertedClaims = this.delegate.convert(claims);
String organization = convertedClaims.get("organization") != null ?
(String) convertedClaims.get("organization") : "unknown";

convertedClaims.put("organization", organization.toUpperCase());

return convertedClaims;
}
}

Затем в нашем классе SecurityConfig нам нужно добавить собственный экземпляр JwtDecoder , чтобы переопределить экземпляр, предоставленный Spring Boot , и установить наш OrganizationSubClaimAdapter в качестве преобразователя утверждений :

@Bean
public JwtDecoder customDecoder(OAuth2ResourceServerProperties properties) {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(
properties.getJwt().getJwkSetUri()).build();

jwtDecoder.setClaimSetConverter(new OrganizationSubClaimAdapter());
return jwtDecoder;
}

Теперь, когда мы нажмем наш /user/info API для пользователя mike@other.com , мы получим организацию как UNKNOWN .

Обратите внимание, что переопределение bean-компонента JwtDecoder по умолчанию, настроенного Spring Boot, должно выполняться осторожно, чтобы убедиться, что вся необходимая конфигурация по-прежнему включена.

6. Загрузка ключей из хранилища ключей Java

В нашей предыдущей конфигурации мы использовали открытый ключ сервера авторизации по умолчанию для проверки целостности нашего токена.

Мы также можем использовать пару ключей и сертификат, хранящиеся в файле хранилища ключей Java, для выполнения процесса подписи.

6.1. Сгенерируйте файл JKS Java KeyStore

Давайте сначала сгенерируем ключи, а точнее файл .jks , используя инструмент командной строки keytool :

keytool -genkeypair -alias mytest 
-keyalg RSA
-keypass mypass
-keystore mytest.jks
-storepass mypass

Команда сгенерирует файл с именем mytest.jks , который содержит наши ключи, открытый и закрытый ключи.

Также убедитесь, что keypass и storepass совпадают.

6.2. Экспорт открытого ключа

Далее нам нужно экспортировать наш открытый ключ из сгенерированного JKS. Для этого мы можем использовать следующую команду:

keytool -list -rfc --keystore mytest.jks | openssl x509 -inform pem -pubkey

Примерный ответ будет выглядеть так:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgIK2Wt4x2EtDl41C7vfp
OsMquZMyOyteO2RsVeMLF/hXIeYvicKr0SQzVkodHEBCMiGXQDz5prijTq3RHPy2
/5WJBCYq7yHgTLvspMy6sivXN7NdYE7I5pXo/KHk4nz+Fa6P3L8+L90E/3qwf6j3
DKWnAgJFRY8AbSYXt1d5ELiIG1/gEqzC0fZmNhhfrBtxwWXrlpUDT0Kfvf0QVmPR
xxCLXT+tEe1seWGEqeOLL5vXRLqmzZcBe1RZ9kQQm43+a9Qn5icSRnDfTAesQ3Cr
lAWJKl2kcWU1HwJqw+dZRSZ1X4kEXNMyzPdPBbGmU6MHdhpywI7SKZT7mX4BDnUK
eQIDAQAB
-----END PUBLIC KEY-----
-----BEGIN CERTIFICATE-----
MIIDCzCCAfOgAwIBAgIEGtZIUzANBgkqhkiG9w0BAQsFADA2MQswCQYDVQQGEwJ1
czELMAkGA1UECBMCY2ExCzAJBgNVBAcTAmxhMQ0wCwYDVQQDEwR0ZXN0MB4XDTE2
MDMxNTA4MTAzMFoXDTE2MDYxMzA4MTAzMFowNjELMAkGA1UEBhMCdXMxCzAJBgNV
BAgTAmNhMQswCQYDVQQHEwJsYTENMAsGA1UEAxMEdGVzdDCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBAICCtlreMdhLQ5eNQu736TrDKrmTMjsrXjtkbFXj
Cxf4VyHmL4nCq9EkM1ZKHRxAQjIhl0A8+aa4o06t0Rz8tv+ViQQmKu8h4Ey77KTM
urIr1zezXWBOyOaV6Pyh5OJ8/hWuj9y/Pi/dBP96sH+o9wylpwICRUWPAG0mF7dX
eRC4iBtf4BKswtH2ZjYYX6wbccFl65aVA09Cn739EFZj0ccQi10/rRHtbHlhhKnj
iy+b10S6ps2XAXtUWfZEEJuN/mvUJ+YnEkZw30wHrENwq5QFiSpdpHFlNR8CasPn
WUUmdV+JBFzTMsz3TwWxplOjB3YacsCO0imU+5l+AQ51CnkCAwEAAaMhMB8wHQYD
VR0OBBYEFOGefUBGquEX9Ujak34PyRskHk+WMA0GCSqGSIb3DQEBCwUAA4IBAQB3
1eLfNeq45yO1cXNl0C1IQLknP2WXg89AHEbKkUOA1ZKTOizNYJIHW5MYJU/zScu0
yBobhTDe5hDTsATMa9sN5CPOaLJwzpWV/ZC6WyhAWTfljzZC6d2rL3QYrSIRxmsp
/J1Vq9WkesQdShnEGy7GgRgJn4A8CKecHSzqyzXulQ7Zah6GoEUD+vjb+BheP4aN
hiYY1OuXD+HsdKeQqS+7eM5U7WW6dz2Q8mtFJ5qAxjY75T0pPrHwZMlJUhUZ+Q2V
FfweJEaoNB9w9McPe1cAiE+oeejZ0jq0el3/dJsx3rlVqZN+lMhRJJeVHFyeb3XF
lLFCUGhA7hxn2xf3x1JW
-----END CERTIFICATE-----

6.3. Конфигурация Maven

Мы не хотим, чтобы файл JKS попадал в процесс фильтрации maven, поэтому обязательно исключим его в pom.xml :

<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<excludes>
<exclude>*.jks</exclude>
</excludes>
</resource>
</resources>
</build>

Если мы используем Spring Boot, нам нужно убедиться, что наш файл JKS добавлен в путь к классам приложения через подключаемый модуль Spring Boot Maven addResources :

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<addResources>true</addResources>
</configuration>
</plugin>
</plugins>
</build>

6.4. Сервер авторизации

Теперь мы настроим Keycloak для использования нашей пары ключей из mytest.jks , добавив ее в раздел KeyProvider файла JSON определения области следующим образом:

{
"id": "59412b8d-aad8-4ab8-84ec-e546900fc124",
"name": "java-keystore",
"providerId": "java-keystore",
"subComponents": {},
"config": {
"keystorePassword": [ "mypass" ],
"keyAlias": [ "mytest" ],
"keyPassword": [ "mypass" ],
"active": [ "true" ],
"keystore": [
"src/main/resources/mytest.jks"
],
"priority": [ "101" ],
"enabled": [ "true" ],
"algorithm": [ "RS256" ]
}
},

Здесь мы установили приоритет равным 101 , больше, чем у любой другой пары ключей для нашего сервера авторизации, и установили для параметра active значение true . Это делается для того, чтобы наш сервер ресурсов выбрал именно эту пару ключей из свойства jwk-set-uri , которое мы указали ранее.

Опять же, эта конфигурация специфична для Keycloak и может отличаться для других реализаций сервера OAuth.

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

В этой краткой статье мы сосредоточились на настройке нашего проекта Spring Security OAuth2 для использования веб-токенов JSON.

Полную реализацию этой статьи можно найти на GitHub .