Single Sign-On с поставщиком Keycloak

В этой статье рассмотрим Single Sign-On с сервером авторизации Keycloak.

Пример состоит из трех частей:

  • сервер авторизации (Keycloak)
  • клиент
  • сервер ресурсов (необязательная часть)

Пример почти такой же, что в статьях про Client Credentials Flow и OAuth2 Authorization Code flow (для упомянутых статей пример един). В этой статье:

  1. Сервер авторизации другой:  вместо Spring Authorization Server — Keycloak.
  2. Убран Client Credentials Flow, акцент на SSO

Сначала займемся Keycloak- будем запускать его в Docker.

Со временем можно столкнуться с проблемой: пока компьютер находится в спящем режиме, время в Docker-контейнере отстает от времени компьютера, и поэтому Access Token становится устаревшим сразу после выпуска, и с сервера ресурсов возвращается 401. В этом случае стоит проверить время в контейнере.

Запуск 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:

http://localhost:8080/* в поле Redirect uri сделает поддерживаемым любой Redirect uri реального клиента (лишь бы он начинался с localhost), но это менее безопасно, чем задать точный.

Выберем Access Type — confidential. Пароль задается на вкладке Credentials в поле secret.

Важно задать Redirect URL — адрес перенаправления на клиент (на скриншоте он, правда, со звездочкой). Сюда пользователь будет перенаправлен после логина на Keycloak:

http://localhost:8080/login/oauth2/code/keyloak

На клиенте потом пропишем такой же url. И такие же client-id и secret.

Здесь же задается возможность поддержать Client Credentials Flow, который рассматривался в той же статье — это флажок Service Accounts Enabled. Во нашем клиенте он не используется, поэтому включен ли он на сервере — не важно.

Основное прописано, ниже мы перейдем к созданию 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

Настройки сервера ресурсов

На сервере ресурсов в application.properties нужно поменять адрес сервера авторизации — у нас теперь Keycloak.
#настройки с сервера ресурсов
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) для обращения на сервер ресурсов.

OAuth2AuthorizedClient  создается после того, как пользователь вводит имя и пароль на Keycloak и перенаправляется обратно на клиент. Создается он в фильтрах OAuth2LoginAuthenticationFilter (если указан oauth2Login()) и OAuth2AuthorizationCodeGrantFilter (если oauth2Client(), как в тех статьях). Но principalName заполняется только в первом случае, во втором это anonymousUser.

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.

Single Sign-On с поставщиком Keycloak: 12 комментариев

  1. Прежде всего: от души! За такой ресурс. Статьи отличные.
    НО!
    Чего реально не хватает, так это общего списка в котором были бы АБСОЛЮТНО ВСЕ статьи этого ресурса. Например, ищу я в yandex материал про docker + spring + postgres и натыкаюсь на статью «Spring Boot + PostgreSQL + JS в Doсker» (https://sysout.ru/spring-boot-postgresql-js-v-dosker/).
    А ведь этой статьи нет в менюшках (которые сверху справа). А если бы был общий список, то я бы сначала искал там любой материал и любые вопросы. Так как статьи с этого сайта я предпочитаю любым другим.
    Почему важно чтобы был список статей в одном месте — чтобы можно было искать с помощью Ctrl + F. Например, я мог бы ввести «docker» и уже сразу нашел бы нужную статью.

    1. Спасибо, я проверю, чего не хватает: но если не подменю выбирать, а сразу Spring, то есть почти все годное (по крайней мере, касающееся Spring), в т.ч. Docker. (кроме Spring Data пока)

      1. Да, действительно. Я даже не знал что это меню нажимается. Думал только подменю кликабельные)

  2. Спасибо за статью!
    Что если мне не нужно, чтобы пользователь видел всю карусель с редирекшином? Т.е. юзер (Андройд) вводит в форме только логин-пароль, которые посылаются на сервер. Там к ним добавляются клиентID с ключом и отправляется к провайдеру (Keycloak). Юзеру возвращаем сессию.
    Как это реализовать c использованием Spring Security?

    1. Без редирекшена — это, видимо, Resource Owner Password Credentials Flow (когда не только Keyloak, а сам клиент получает пароль). У меня нет такого примера, но в документации про этот flow тут и тут. Веб-приложение наверно нужно сделать клиентом (spring-boot-starter-oauth2-client).
      Причем если отдельного сервера ресурсов нет, а прямо на клиенте надо защитить url, то вместо oauth2Client() прописать oauth2Login() в настройках HttpSecurity.

  3. Здравствуйте, подскажите что в данном примере делает бин WebSecurityCustomizer webSecurityCustomizer(). не поняла из примера

    1. В строке web.ignoring().antMatchers(«/webjars/**») сказано, что Spring Security не должен препятствовать загрузке статических ресурсов из папки /webjars — там может быть jquery, bootstrap.js и т.п. Мы не используем, но теоретически надо, чтобы они грузились.

      1. Спасибо! И ещё появился вопрос. Я разрабатываю только клиента и защищаю внутренне url, как вы и писали. Дополнительно добавила success Handler, который сохраняет новых пользователей keykloackв бд клиента после регистрации в keykloack. И в браузере это отрабатывает отлично, но в постман невозможно не то что сохранить, даже просто обратиться к внутреннему URL. Не понимаю почему так происходит, может у вас есть предположения?

  4. Не подскажите почему у меня при запуске
    docker-compose up -d
    процесс keycloak висит в статусе exited
    при этом у postgres статус running
    Пробую посмотреть логи, там только
    opt/jboss/keycloak/standalone/configuration/keycloak-add-user.json (Is a directory)
    Зайти в контейнер я не могу, т.к. он не существует

  5. В данном коде используется jboss/keycloak
    Но в офф сайте он уже давно DEPRECATED.
    Я так понимаю, что Keycloak перешел на Quarkus.
    А Spring собирается переходить на новую версию?
    Или уже есть ее поддержка?

  6. И еще вопрос.
    Вы используете scopes. Я так понимаю, что это вместо ролей.
    А почему именно их?
    И в чем разница или преимущество?
    И я не увидел, где происходит привязка scopes с конкретным пользователем.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *