В этой статье рассмотрим 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 с конкретным пользователем.