В этой статье рассмотрим Single Sign-On с сервером авторизации Keycloak.
Пример состоит из трех частей:
- сервер авторизации (Keycloak)
- клиент
- сервер ресурсов (необязательная часть)
Пример почти такой же, что в статьях про Client Credentials Flow и OAuth2 Authorization Code flow (для упомянутых статей пример един). В этой статье:
- Сервер авторизации другой: вместо Spring Authorization Server — Keycloak.
- Убран Client Credentials Flow, акцент на SSO
Сначала займемся Keycloak- будем запускать его в Docker.
Запуск Keycloak в Docker
Итак, установим Docker. Запустим Keycloak в Docker с помощью команды:
docker-compose up
Файл docker-compose.yml прилагается (обратите внимание на строку — «9000:8080», она означает, что Keycloak будет работать на порту 9000 для нас):
version: "3.8"
services:
postgres:
container_name: postgres
image: library/postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: keycloak_db
ports:
- "5432:5432"
restart: unless-stopped
keycloak:
image: jboss/keycloak
container_name: keycloak
environment:
DB_VENDOR: POSTGRES
DB_ADDR: postgres
DB_DATABASE: keycloak_db
DB_USER: ${POSTGRES_USER:-postgres}
DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
KEYCLOAK_USER: admin
KEYCLOAK_PASSWORD: admin_password
ports:
- "9000:8080"
depends_on:
- postgres
links:
- "postgres:postgres"
Теперь если зайти по адресу
http://localhost:9000/
откроется вход в панель администратора (имя и пароль тоже смотрите в docker.yml — это admin и admin_password):

Поскольку у нас будет еще клиент на Spring Boot на localhost:8080, с которого пользователи будут перенаправляться на Keycloak для авторизации (а потом обратно), то чтобы избежать перезаписи cookies дадим серверу Keycloak псевдоним auth-server файле в hosts.
В C:\Windows\System32\drivers\etc\hosts добавим строку 127.0.0.1 auth-server. И далее будем обращаться к Keycloak как auth-server:9000 — с клиента и с сервера ресурсов.
Теперь нужно зарегистрировать на Keycloak будущий клиент. И заодно пользователей, поскольку мы рассматриваем Authorization Code Flow — в нем участвуют не только client credentials, но и пользователь, который разрешает клиенту доступ к ресурсам.
Добавление клиента и пользователей на Keycloak
Прежде чем зарегистрировать клиента и пользователей, нужно создать realm — это подраздел в Keycloak. И в нем уже регистрировать. Создадим realm с именем demo (кнопка Add realm выплывает в левом верхнем углу).
Регистрация клиента
Затем создадим на нем клиента messaging-client:

Выберем Access Type — confidential. Пароль задается на вкладке Credentials в поле secret.
Важно задать Redirect URL — адрес перенаправления на клиент (на скриншоте он, правда, со звездочкой). Сюда пользователь будет перенаправлен после логина на Keycloak:
http://localhost:8080/login/oauth2/code/keyloak
На клиенте потом пропишем такой же url. И такие же client-id и secret.
Основное прописано, ниже мы перейдем к созданию Spring Boot клиента. На нем нужно будет внести только что сделанные настройки клиента, чтобы он мог считаться тем самым клиентом, которого мы только что зарегистрировали.
Забыли про разрешения scopes — их тоже нужно задать в интерфейсе Keycloak, они же будут authorities (разрешения) на сервере ресурсов (поскольку он у нас тоже есть), предваренные префиксом SCOPE_. Сначала их нужно просто добавить в пункте Client Scopes, а затем в пункте Clients выбрать наш messaging-client и добавить для него созданные scopes. У нас это будут:
message.read message.write

Далее, поскольку у нас Authorization Code Flow, подразумевающий людей-владельцев ресурсов, нужно зарегистрировать еще пользователей.
Добавление пользователей
Зарегистрируем одного. Зададим его имя bob. Затем пароль на вкладке Credentials. 
Теперь перейдем к созданию клиента.
Настройки клиента
Код остается почти таким же, как в предыдущей статье, только поменяются некоторые настройки регистрации:
- Теперь authorization-uri страницы авторизации и token-uri получения токенов указывают не на Spring Authorization Server, а на сервер Keycloak.
- Также client-secret берем тот, который сгенерировался (или мы сами создали) на Keycloak.
- Убрали вариант Client Credentials Grant Flow.
- Поскольку у нас SSO, добавили адрес конечной точки userinfo (информация о пользователе) — это user-info-uri. И user-name-attribute — это какой атрибут пользователя использовать в качестве имени аутентифицированного пользователя OAuth2User.
spring:
thymeleaf:
cache: false
security:
oauth2:
client:
registration:
keyloak:
client-id: messaging-client
client-secret: 4953cb21-7dce-43cf-831d-004495a05509
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/keyloak"
scope: message.read,message.write
provider:
keyloak:
authorization-uri: http://keyloak-server:9000/auth/realms/demo/protocol/openid-connect/auth
token-uri: http://keyloak-server:9000/auth/realms/demo/protocol/openid-connect/token
user-info-uri: http://keyloak-server:9000/auth/realms/demo/protocol/openid-connect/userinfo
user-name-attribute: preferred_username
mvc:
log-request-details: true
#url сервера ресурсов
messages:
base-uri: http://localhost:8090/messages
Настройки сервера ресурсов
#настройки с сервера ресурсов
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://keyloak-server:9000/auth/realms/demo/protocol/openid-connect/certs
issuer-uri: http://keyloak-server:9000/auth/realms/demo
Сервер ресурсов обращается на Keycloak, чтобы проверить полученный от клиента Access Token. И только если он подлинный, возвращает ресурс. (Еще в токене должно быть указано разрешение на ресурс и по нему сервер ресурсов смотрит, разрешено ли отдавать ресурс).
Если не считать новых uri, сервер ресурсов остается без изменений, весь код тут. Но основное повторю.
MessagesController выдает сообщения в ответ на запрос с Access Token-ом:
@RestController
public class MessagesController {
@GetMapping("/messages")
public String[] getMessages() {
return new String[] {"Message 1", "Message 2", "Message 3"};
}
}
Ниже в конфигурации прописано, какие разрешения должны содержаться в токене, чтобы сообщения выдавались — это message.read:
@EnableWebSecurity
public class ResourceServerConfig extends WebSecurityConfigurerAdapter {
// @formatter:off
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.mvcMatcher("/messages/**")
.authorizeRequests()
.mvcMatchers("/messages/**").access("hasAuthority('SCOPE_message.read')")
.and()
.oauth2ResourceServer()
.jwt();
}
// @formatter:on
}
В консоли сервера ресурсов видны обращения к Keycloak для проверки полученных токенов:
o.s.web.client.RestTemplate : HTTP GET http://keyloak-server:9000/auth/realms/demo/protocol/openid-connect/certs
Код клиента
Код сервера ресурсов не поменялся, а код клиента — да. Ключевой момент — oauth2Login() вместо oauth2Client():
@EnableWebSecurity
public class SecurityConfig {
@Bean
WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().antMatchers("/webjars/**");
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http.authorizeRequests()
.antMatchers("/", "/index").permitAll()
.anyRequest().authenticated()
.and()
.oauth2Login();
return http.build();
}
// @formatter:on
}
Кроме того, здесь мы закрываем все страницы, кроме главной. И говорим, что аутентификация происходит с помощью OAuth2 — обращением на прописанный в настройках сервер сервер авторизации Keycloak.
oauth2Login() vs oauth2Client()
Оба метода делают так, что для залогиненного пользователя создается OAuth2AuthorizedClient со всем данными (Access Token, Refresh Token) для доступа к серверу ресурсов. Но oauth2Login() также заполняет Principal-а, создает на самом клиенте аутентифицированного пользователя, для которого можно настроить доступ к ресурсам клиента.
То есть благодаря oauth2Login() мы можем защитить также и локальные url, а не только сервер ресурсов. Как было написано в коде выше, все локальные url защищены, кроме главной страницы. Теперь к локальным защищенным url может обращаться только аутентифицированный через Keycloak пользователь.
Ниже рассмотрим контроллеры, которые выдают защищенные страницы.
DefaultController
DefaultController выдает открытую главную страницу и защищенную /localmessages, к которой может обратиться только аутентифицированный пользователь. Сама попытка обращения перенаправляет на Keycloak для аутентификации. На ней же выдается имя пользователя.
@Controller
public class DefaultController {
@GetMapping("/")
public String root() {
return "index";
}
@GetMapping("/localmessages")
public String local(Model model, @AuthenticationPrincipal OAuth2User oauth2User) {
model.addAttribute("message", "protected message");
model.addAttribute("username", oauth2User.getName());
return "local";
}
}
AuthorizationController
По адресу /remotemessages выдаются сообщения с сервера ресурсов:
@Controller
public class AuthorizationController {
private final WebClient webClient;
private final String messagesBaseUri;
public AuthorizationController(WebClient webClient,
@Value("${messages.base-uri}") String messagesBaseUri) {
this.webClient = webClient;
this.messagesBaseUri = messagesBaseUri;
}
@GetMapping(value = "/remotemessages")
public String authorizationCodeGrant(Model model,
@AuthenticationPrincipal OAuth2User oauth2User) {
String[] messages = this.webClient
.get()
.uri(this.messagesBaseUri)
.retrieve()
.bodyToMono(String[].class)
.block();
model.addAttribute("messages", messages);
model.addAttribute("username", oauth2User.getName());
return "remotemessages";
}
// '/authorized' is the registered 'redirect_uri' for authorization_code
@GetMapping(value = "/authorized", params = OAuth2ParameterNames.ERROR)
public String authorizationFailed(Model model, HttpServletRequest request) {
String errorCode = request.getParameter(OAuth2ParameterNames.ERROR);
if (StringUtils.hasText(errorCode)) {
model.addAttribute("error",
new OAuth2Error(
errorCode,
request.getParameter(OAuth2ParameterNames.ERROR_DESCRIPTION),
request.getParameter(OAuth2ParameterNames.ERROR_URI))
);
}
return "index";
}
}
Мало того, что сама страница /remotemessages защищена, она для получения данных с сервера ресурсов она использует WebClient, которому для работы нужен OAuth2AuthorizedClient со всеми настройками (Access Token) для обращения на сервер ресурсов.
WebClient настроен в классе WebClientConfig.
WebClientConfig
Тут в методе setDefaultOAuth2AuthorizedClient() говорится, что используем OAuth2AuthorizedClient по умолчанию.
@Configuration
public class WebClientConfig {
@Bean
WebClient webClient(ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(clientRegistrationRepository,
authorizedClientRepository);
oauth2Client.setDefaultOAuth2AuthorizedClient(true);
return WebClient.builder().
apply(oauth2Client.oauth2Configuration())
.build();
}
}
Запуск
Запустим сервер ресурсов, клиент.
Перейдем по адресу localhost:8080 (тут клиент). Откроется выбор из двух пунктов:

При любом выборе мы будем перенаправлены на сервер Keycloak:

После ввода имени и пароля зарегистрированного пользователя bob/*** мы будем перенаправлены обратно на клиент c неким code в url. Клиент POST-запросом с code получит Access-токен с Keycloak и создаст OAuth2AuthorizedClient (этот запрос виден в консоли, за ним в консоли идет GET-обращение на userinfo, так как oauth2Login()).
Далее либо просто заполнится страница /localmessage (если щелкнули первую ссылку):
Либо (если щелкнули ссылку /remotemessages) будет обращение к серверу ресурсов с Access-токеном за списком сообщений. (GET-запрос на сервер ресурсов отобразится в консоли). И мы увидим сообщения:

Итоги
Код примера есть на GitHub.
Прежде всего: от души! За такой ресурс. Статьи отличные.
НО!
Чего реально не хватает, так это общего списка в котором были бы АБСОЛЮТНО ВСЕ статьи этого ресурса. Например, ищу я в yandex материал про docker + spring + postgres и натыкаюсь на статью «Spring Boot + PostgreSQL + JS в Doсker» (https://sysout.ru/spring-boot-postgresql-js-v-dosker/).
А ведь этой статьи нет в менюшках (которые сверху справа). А если бы был общий список, то я бы сначала искал там любой материал и любые вопросы. Так как статьи с этого сайта я предпочитаю любым другим.
Почему важно чтобы был список статей в одном месте — чтобы можно было искать с помощью Ctrl + F. Например, я мог бы ввести «docker» и уже сразу нашел бы нужную статью.
Спасибо, я проверю, чего не хватает: но если не подменю выбирать, а сразу Spring, то есть почти все годное (по крайней мере, касающееся Spring), в т.ч. Docker. (кроме Spring Data пока)
Да, действительно. Я даже не знал что это меню нажимается. Думал только подменю кликабельные)
Спасибо за статью!
Что если мне не нужно, чтобы пользователь видел всю карусель с редирекшином? Т.е. юзер (Андройд) вводит в форме только логин-пароль, которые посылаются на сервер. Там к ним добавляются клиентID с ключом и отправляется к провайдеру (Keycloak). Юзеру возвращаем сессию.
Как это реализовать c использованием Spring Security?
Без редирекшена — это, видимо, Resource Owner Password Credentials Flow (когда не только Keyloak, а сам клиент получает пароль). У меня нет такого примера, но в документации про этот flow тут и тут. Веб-приложение наверно нужно сделать клиентом (spring-boot-starter-oauth2-client).
Причем если отдельного сервера ресурсов нет, а прямо на клиенте надо защитить url, то вместо oauth2Client() прописать oauth2Login() в настройках HttpSecurity.
Здравствуйте, подскажите что в данном примере делает бин WebSecurityCustomizer webSecurityCustomizer(). не поняла из примера
В строке web.ignoring().antMatchers(«/webjars/**») сказано, что Spring Security не должен препятствовать загрузке статических ресурсов из папки /webjars — там может быть jquery, bootstrap.js и т.п. Мы не используем, но теоретически надо, чтобы они грузились.
Спасибо! И ещё появился вопрос. Я разрабатываю только клиента и защищаю внутренне url, как вы и писали. Дополнительно добавила success Handler, который сохраняет новых пользователей keykloackв бд клиента после регистрации в keykloack. И в браузере это отрабатывает отлично, но в постман невозможно не то что сохранить, даже просто обратиться к внутреннему URL. Не понимаю почему так происходит, может у вас есть предположения?
Не подскажите почему у меня при запуске
docker-compose up -d
процесс keycloak висит в статусе exited
при этом у postgres статус running
Пробую посмотреть логи, там только
opt/jboss/keycloak/standalone/configuration/keycloak-add-user.json (Is a directory)
Зайти в контейнер я не могу, т.к. он не существует
вопрос снят, удалить возможности нет
В данном коде используется jboss/keycloak
Но в офф сайте он уже давно DEPRECATED.
Я так понимаю, что Keycloak перешел на Quarkus.
А Spring собирается переходить на новую версию?
Или уже есть ее поддержка?
И еще вопрос.
Вы используете scopes. Я так понимаю, что это вместо ролей.
А почему именно их?
И в чем разница или преимущество?
И я не увидел, где происходит привязка scopes с конкретным пользователем.