Альтернатива Keycloak: как настроить SSO в Authentik

в 15:15, , рубрики: authentik, java, keycloak, spring boot, Spring Security, SSO

Привет!

Не так давно передо мной встала задача настроить единый вход (SSO) в наше приложение, и так исторически сложилось, что в компании для этого используют open-source-провайдер аутентификации authentik.

Первым делом я, конечно, полез в официальную документацию провайдера, но, к сожалению, не нашел там каких-то подробных гайдов или туториалов по настройке. Дальше я, само собой, решил погуглить — нашел статьи по запуску authentik, а вот более-менее подробного туториала, как все это заставить работать, используя наш стек (Java, Spring Boot), не оказалось. Поэтому я решил сделать его сам.

В данной статье мы не будем останавливаться на описании SSO, разборе Keycloak или создании приложения Spring Boot — на Хабре уже немало статей, которые отвечают на эти вопросы. Вот некоторые из них: что такое SSO, еще раз про SSO и протоколы, про Keycloak.

Начнем с того, что просто запустим сервер authentik.

На сайте authentik можно найти актуальные сетапы для Docker Compose и для Kubernetes. Мы будем использовать Docker Compose:

docker-compose.yml
services:
  postgres: ## аусентик использует PG как основную БД
    image: postgres:16-alpine
    container_name: postgres
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: authentik
      POSTGRES_PASSWORD: password
      POSTGRES_DB: authentik_demo

  redis:
    image: redis:alpine
    container_name: redis
    restart: always
    ports:
      - "6379:6379"
    command: "--requirepass authentik"


  server:
    image: ghcr.io/goauthentik/server:2024.6.3
    restart: unless-stopped
    command: server
    environment:
      AUTHENTIK_EMAIL__HOST: mailhog
      AUTHENTIK_EMAIL__PORT: 1025
      AUTHENTIK_EMAIL__USE_TLS: false
      AUTHENTIK_EMAIL__USE_SSL: false
      AUTHENTIK_EMAIL__FROM: authentik@localhost
      AUTHENTIK_REDIS__HOST: redis
      AUTHENTIK_REDIS__PASSWORD: authentik
      AUTHENTIK_POSTGRESQL__HOST: postgres
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik_demo
      AUTHENTIK_POSTGRESQL__PASSWORD: password
      AUTHENTIK_SECRET_KEY: auth # super secret key
      AUTHENTIK_LOG_LEVEL: trace
    ports:
      - "9005:9000"
      - "9443:9443"
    depends_on:
      - postgres
      - redis

  worker:
    image: ghcr.io/goauthentik/server:2024.6.3
    restart: unless-stopped
    command: worker
    environment:
      AUTHENTIK_EMAIL__HOST: mailhog
      AUTHENTIK_EMAIL__PORT: 1025
      AUTHENTIK_EMAIL__USE_TLS: false
      AUTHENTIK_EMAIL__USE_SSL: false
      AUTHENTIK_EMAIL__FROM: authentik@localhost
      AUTHENTIK_REDIS__HOST: redis
      AUTHENTIK_REDIS__PASSWORD: authentik
      AUTHENTIK_POSTGRESQL__HOST: postgres
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik_demo
      AUTHENTIK_POSTGRESQL__PASSWORD: password
      AUTHENTIK_SECRET_KEY: auth # super secret key
    user: root
    depends_on:
      - postgres
      - redis
      - mailhog

  mailhog: # мок smtp сервер, UI доступен на порту 8025 (можно посмотреть все отправленные письма)
    image: mailhog/mailhog
    container_name: mailhog
    ports:
      - "1025:1025"
      - "8025:8025"

Запускаем сервисы командой docker-compose up -d и необходимо немного подождать, пока сервер authentik запустится и мы сможем перейти к настройке.

Первоначальная настройка authentik

Как только сервис запустится, необходимо открыть UI для первоначальной настройки сервера authentik (он доступен по ссылке http://localhost:9005/if/flow/initial-setup/). Тут всё просто - необходимо задать логин и пароль для суперадмина, после установки пароля вы окажетесь на странице-библиотеке приложений, вам необходимо в верхней правой части экрана выбирать вкладку Admin Interface, которая откроет нам UI настройки нашего authentik.

Создание приложения и провайдера аутентификации

Первым делом создадим наше приложение. Слева, в панели навигации, заходим в Applications -> Applications. Далее используем Wizard, который сразу создаст сущность провайдера (метод аутентификации) для нашего приложения.

Альтернатива Keycloak: как настроить SSO в Authentik - 1
Шаг 1 - Задаем имя нашего приложения
У нас будет super-app

У нас будет super-app

Шаг 2 - Выбираем тип провайдера

Мы используем OAuth2/OIDC, его и выбираем

Альтернатива Keycloak: как настроить SSO в Authentik - 3
Шаг 3 - Настраиваем провайдера
Альтернатива Keycloak: как настроить SSO в Authentik - 4

Вводим имя провайдера и выбираем Authentication flow — шаги, через которые пройдет пользователь во время аутентификации. Cам флоу очень гибкий, его можно в любой момент поменять. Можно (скорее даже нужно) создать свой кастомный Authentication flow и использовать его, но в этом гайде для простоты я использую уже имеющийся дефолтный default-authentication-flow.

Выбираем Authorization flow: default-provider-authorization-explicit-consent — явно спросит у пользователя разрешения использовать его данные, которые он ввел в форму (username, email или другие данные, которые вы добавите в форму). default-provider-authorization-implicit-consent — ничего не спросит и просто авторизует

Альтернатива Keycloak: как настроить SSO в Authentik - 5

Вводим Redirect URI http://localhost:8080/login/oauth2/code/super-appв котором:
/login/oauth2/code — это дефолтный path для фильтра в Spring Security (OAuth2LoginAuthenticationFilter.class)
super-app — это registrationId нашего приложения в Spring Security.

И жмем SubmitНастройка потоков аутентификации и восстановления пароляНастройка потоков аутентификации и восстановления пароляНастройка потоков аутентификации и восстановления пароляНастройка потоков аутентификации и восстановления пароля

Настройка потоков аутентификации и восстановления пароля

Создаем stage (шаг) отправки email созданному пользователю с просьбой установить пароль. Делаем это через вкладку Flows And Stages → Stages -> Create.

Выбираем Email Stage
Альтернатива Keycloak: как настроить SSO в Authentik - 6

Настраиваем Email Stage
Альтернатива Keycloak: как настроить SSO в Authentik - 7

Token expiry — число минут активности ссылки в письме.
Subject — тема письма.
Template — шаблон письма (шаблоны можно создавать свои, дока).

Создаем флоу установки пароля, на который мы будем отправлять пользователя из письма. Делаем это через вкладку Flows And Stages → Flows -> Create.

Создаем flow
Создание flow

Создание flow

Вводим имя, заголовок (то как он будет отображаться на странице со всеми флоу) и slug (то имя флоу, которое будет отображаться в URL).
Выбираем назначение флоу - Designation: Recovery
Аутентификация для данного флоу не требуется, поэтому оставляем No requirement.

Настраиваем flow

Заходим в созданный flow (кликая на него), переходим на вкладку Stage Bindings и добавляем шаги, лишь указывая их порядок

Шаги восстановления пароля

Шаги восстановления пароля
  1. Созданный нами email шаг

  2. Шаг формы ввода пароля

  3. Присвоение пароля пользователю

  4. Аутентификация пользователя

Если вы хотите задать кастомную валидацию пароля пользователя, это можно сделать, создав Policy (Customization -> Policy -> Create -> Password Policy) и прикрепив ее к шагу stage-default-oobe-password:

Альтернатива Keycloak: как настроить SSO в Authentik - 10

Управление пользователями и группами

Заходим во вкладку Brands (System -> Brands), она позволяет кастомизировать UI и поведение для разных доменов и обновляем дефолтный бренд (или создаем новый для вашего домена), устанавливая наш password-recovery-flow в качестве дефолтного (когда мы отправим письмо пользователю, используя email stage, созданный ранее, он использует этот дефолтный флоу для установки пароля)

Обновляем бренд
Альтернатива Keycloak: как настроить SSO в Authentik - 11

После этого создаем группу для пользователей. Пользователи могут быть сгруппированы по различным признакам, мы сгруппируем по окружению и приложению и получим имя группы dev-super-app-user. В будущем мы можем завести группы под другие окружения: stage-super-app-user или приложения dev-another-app-user и назначая пользователю группы мы сможем управлять его доступом к окружению и приложению.

Создаем группу пользователей
Альтернатива Keycloak: как настроить SSO в Authentik - 12

Создаем сервисного пользователя для нашего приложения (Directory -> Users -> Create)

Создаем сервисного пользователя
Альтернатива Keycloak: как настроить SSO в Authentik - 13

Выбираем User type: Service account

Также создадим токен (Directory -> Tokens and App Passwords) для использования API authentik

Создаем токен
Альтернатива Keycloak: как настроить SSO в Authentik - 14

Скопировать токен можно, нажав на эту кнопку

Альтернатива Keycloak: как настроить SSO в Authentik - 15

Замечу, что на данный момент пользователь не имеет разрешений, чтобы использовать API, хоть у нас и есть токен. Предоставим их созданному пользователю (Directory -> Users). Нажимаем на пользователя (или выбираем кнопку «Редактировать» справа), переходим на вкладку Permissions и назначаем нужные разрешения

Редактируем сервисного пользователя
Вкладка permissions

Вкладка permissions
Необходимые разрешения

Необходимые разрешения

На этом с authentik мы закончили и можем настроить наше приложение Spring Boot.

Интеграция с приложением Spring Boot

Как я уже упомянул ранее, подробно разбирать создание приложения Spring Boot в этой статье мы не будем, рабочее приложение для теста вы можете найти на GitHub. Остановлюсь на нескольких моментах:

private static final RequestMatcher ADMIN_REQUEST_MATCHER = new AntPathRequestMatcher("/admin/**");

@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
                                       AuthentikConfigurationProperties properties,
                                       UserAuthenticationFilter authenticationFilter) {
    return http
        .cors(AbstractHttpConfigurer::disable)
        .csrf(AbstractHttpConfigurer::disable)
        .exceptionHandling(
            exceptionConfig -> exceptionConfig
                .authenticationEntryPoint(
                    // in a case of security exception send user to login page
                    new LoginUrlAuthenticationEntryPoint(properties.getUserGroup().getAuthorizationEntryPoint())
                )
        )
        .authorizeHttpRequests(request -> request
            .requestMatchers(ADMIN_REQUEST_MATCHER)
            .authenticated()
            .anyRequest()
            .permitAll()
        )
        .addFilterBefore(authenticationFilter, AnonymousAuthenticationFilter.class)
        .oauth2Login(Customizer.withDefaults())
        .build();
}

exceptionHandling → в случае какого-либо spring security exception мы отправляем пользователя на форму логина.
authorizeHttpRequests → в приложении есть 2 ресурса (/users, /admin), сделаем один из них защищенным аутентификацией.
oauth2Login → используем дефолты, все настроено в application.yml.

@Slf4j
@Component
@RequiredArgsConstructor
public class UserAuthenticationFilter extends OncePerRequestFilter {

    private static final String GROUPS_CLAIM = "groups";

    private final AuthentikConfigurationProperties properties;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        final var authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication != null && authentication.isAuthenticated()) {

            if (authentication.getPrincipal() instanceof OidcUser oidcUser && !isUserMemberOfRequiredGroup(oidcUser)) {
                handleUnauthorizedUser(request);
                return;
            }
        }

        filterChain.doFilter(request, response);
    }

    private boolean isUserMemberOfRequiredGroup(OidcUser user) {
        final var groups = user.getClaimAsStringList(GROUPS_CLAIM);
        return groups != null && groups.contains(properties.getUserGroup().getName());
    }

    // User authorized in Authentik, but groups does not belong to the current application group
    private void handleUnauthorizedUser(HttpServletRequest request) {
        SecurityContextHolder.getContext().setAuthentication(null);
        invalidateSession(request);
        throw new AccessDeniedException("Access denied");
    }

    private void invalidateSession(HttpServletRequest request) {
        final var session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }
    }
}

Данный фильтр нужен на случай, если кто-то вручную зайдет в админку authentik (кто-нибудь из СБ, например) и удалит у пользователя группу, относящуюся к нашему приложению: например, если пользователь — это увольняющийся сотрудник компании.

Особенности работы с пользователями

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

У нас есть внешние пользователи (наши клиенты), и, имея достаточно высокую роль, эти пользователи могут создавать других пользователей (а их могут быть тысячи) внутри своего контура. Соответственно, за создание пользователей и управление их активностью отвечает наша система (наш super-app), а с authentik мы взаимодействуем через API. Админ жмет кнопку «Создать» в форме создания пользователя, мы идем в authentik, создаем там пользователя, затем создаем его в нашей системе и отправляем пользователю письмо с просьбой установить пароль.

Помимо описанного выше флоу, пользователь может создаваться через enrollment flow. Это будет выглядеть так: 

  1. Создаете invitation link (ссылку-приглашение) и отправляет ее новому пользователю (можно также завернуть в красивую html). (это может делать ваше приложение или админ напрямую из authentik)

  2. Новый пользователь переходит по ссылке и проходит процедуру регистрации.

Ну и, конечно, возможен самый простой способ — это просто сделать на форме логина кнопку «Зарегистрироваться», которая отправляет пользователя на тот же Enrollment flow.

Создание и обновление юзера

@Service
@RequiredArgsConstructor
public class AuthentikAuthenticationProviderPort implements AuthenticationProviderPort {

    private final AuthentikClient client;

    private final AuthentikAuthenticationProviderPortMapper mapper;

    @Override
    public ExternalProviderUserModel create(CreateUserRequestModel request) {
        final var existingUsers = client.findUsersByEmail(request.getEmail());

        if (existingUsers.getResults().isEmpty()) {
            final var user = client.createUser(request.getEmail());
            client.sendUserResetPassword(user.getId());
            return mapper.mapToModel(user);
        }

        final var existingUser = existingUsers.getResults().getFirst();
        final var updatedUser = client.updateUserGroup(existingUser, true);
        return mapper.mapToModel(updatedUser);
    }

    /**
     * Since Authentik is used for multiple applications (SSO) we control access through the user groups
     * we can't just enable/disable user because it can affect other applications
     * so we should add or remove the group of our application
     */
    @Override
    public void updateStatus(String id, Boolean active) {
        final var user = client.findUserById(id);
        client.updateUserGroup(user, active);
    }
}

Поясню саму логику создания и обновления юзера.

Предположим, что в нашей системе пользователь уникален по email (кстати, в authentik такой уникальности нет, там есть только уникальность по username, но есть гайд, как это сделать, тут), тогда при создании пользователя нам сначала нужно поискать его в authentik, вдруг пользователь уже создан другим приложением или какой-нибудь выгрузкой с AD компании. Тут три варианта результата:

  1. Если ничего не найдено — создаем нового пользователя и отправляем ему письмо для установки пароля.

  2. Если найден 1 пользователь — добавляем ему группу нашего приложения.

  3. Если найдено более 1 пользователя — для нас это исключительная ситуация, и мы предполагаем, что такое невозможно (exception кидается в клиенте).

Так как authentik может использоваться как единая точка входа для многих приложений, мы не можем просто отключить пользователя в authentik (это повлияет сразу на все приложения, с которыми связан пользователь). Если кто-то хочет отключить его в нашем приложении, мы должны удалить группу нашего приложения (и когда админ захочет включить его снова, мы вернем эту группу).

application.yml

server:
  port: ${SERVER_PORT:8080}

spring:
  application:
    name: authentik-demo
  security:
    oauth2:
      client:
        registration:
          super-app:
            provider: super-app
            ## you can find client id/secret in Authentik Applications -> Providers, click your provider and tap Edit
            client-id: ${AUTHENTIK_SUPER_APP_CLIENT_ID:}
            client-secret: ${AUTHENTIK_SUPER_APP_CLIENT_SECRET:}
            scope: openid,profile,email
            redirect-uri: ${AUTHENTIK_SUPER_APP_REDIRECT_URI:http://localhost:8080/login/oauth2/code/super-app}
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
        provider:
          super-app:
            ## you can these uris in Authentik Applications -> Providers, click your provider
            authorization-uri: ${AUTHENTIK_SUPER_APP_AUTHORIZATION_URI:http://localhost:9005/application/o/authorize/}
            token-uri: ${AUTHENTIK_SUPER_APP_TOKEN_URI:http://localhost:9005/application/o/token/}
            user-info-uri: ${AUTHENTIK_SUPER_APP_USER_INFO_URI:http://localhost:9005/application/o/userinfo/}
            user-name-attribute: sub
            issuer-uri: ${AUTHENTIK_SUPER_APP_ISSUER_URI:http://localhost:9005/application/o/super-app/}
            jwk-set-uri: ${AUTHENTIK_SUPER_APP_JWK_URI:http://localhost:9005/application/o/super-app/jwks/}

app:
  authentik:
    base-url: ${AUTHENTIK_BASE_URL:http://localhost:9005}
    find-user-uri: ${AUTHENTIK_FIND_USERS_URI:/api/v3/core/users/}
    find-user-by-id-uri: ${AUTHENTIK_FIND_USER_BY_ID_URI:/api/v3/core/users/{id}/}
    create-user-uri: ${AUTHENTIK_CREATE_USER_URI:/api/v3/core/users/}
    update-user-uri: ${AUTHENTIK_UPDATE_USER_URI:/api/v3/core/users/{id}/}
    reset-user-password-uri: ${AUTHENTIK_RESET_USER_PASSWORD_URI:/api/v3/core/users/{id}/recovery_email/}
    api-token: ${AUTHENTIK_API_TOKEN:}
    email-stage-identity: ${AUTHENTIK_EMAIL_STAGE_IDENTITY:}
    user-group:
      authorization-entry-point: /oauth2/authorization/super-app
      id: ${AUTHENTIK_USER_GROUP_ID:}
      name: ${AUTHENTIK_USER_GROUP_NAME:dev-super-app-user}
      user-type: ${AUTHENTIK_USER_TYPE:external}

Разберем конфиг немного подробнее:

client-id/client-secret → вы можете их найти, кликнув на созданного провайдера и нажав Edit в authentik.
redirect-uri → должна совпадать с redirect url в настройках провайдера.
email-stage-identity → чтобы получить этот id, нужно зайти во Flows and Stages -> Stages, найти созданный нами ранее password-recovery-stage, открыть dev tools в браузере, нажать на иконку карандашика справа и скопировать id из запроса /api/v3/stages/email/{id}/

Email stage id в dev tools
Альтернатива Keycloak: как настроить SSO в Authentik - 18

user-group.id → тут проще: заходим Directory -> Groups, кликаем на нашу группу и из адресной строки берем ID.

User group id
Альтернатива Keycloak: как настроить SSO в Authentik - 19

user-group.name → это просто имя группы, в нашем случае dev-super-app-user.

user-group.user-type → здесь существует тонкая грань между internal и external. Заключается она в том, что external-пользователи не имеют доступа к authentik admin UI. В то же время internal-пользователь может зайти на стартовую страницу authentik и увидеть все доступные ему приложения, выбрать приложение и залогиниться туда, external эту страницу просто не видит. Тут решать вам, какой вариант больше подходит.

user-group.name → это просто имя группы, в нашем случае dev-super-app-user.
user-group.user-type
→ здесь существует тонкая грань между internal и external. Заключается она в том, что external-пользователи не имеют доступа к authentik admin UI. В то же время internal-пользователь может зайти на стартовую страницу authentik и увидеть все доступные ему приложения, выбрать приложение и залогиниться туда, external эту страницу просто не видит. Тут решать вам, какой вариант больше подходит.

Пример как выглядит стартовая страница internal пользователя
Альтернатива Keycloak: как настроить SSO в Authentik - 20

Теперь можем запустить приложение, создать пользователя и проверить аутентификацию.

Создаем пользователя запросом

curl --location 'http://localhost:8080/users' 
--header 'Content-Type: application/json' 
--data-raw '{
  "email": "email@gmail.com"
}'

Открываем http://localhost:8025 (на этом порту крутится mock smpt сервис) и видим входящее письмо

Входящее письмо
Альтернатива Keycloak: как настроить SSO в Authentik - 21

Переходим по ссылке и устанавливаем пароль.

Переходим по ссылке и устанавливаем пароль.

Заходим в браузер и открываем http://localhost:8080/admin/current/email. Этот ресурс защищен аутентификацией и возвращает email аутентифицированного пользователя. Нас редиректит на форму входа, вводим логин и пароль и входим в нашу систему

Мы вошли!
Альтернатива Keycloak: как настроить SSO в Authentik - 22

Решение проблем: CORS и редиректы

Решение проблем: CORS и редиректы

В GitHub authentik есть несколько issue, связанных с CORS: мы тоже столкнулись с этой проблемой, и редиректы, которые делает Spring Security, не работали. Чтобы обойти проблему, пришлось придумать костыль.

Для начала создаем свою redirect strategy (она используется, когда Spring Security нужно сделать редирект), наследуем ее от дефолтной спринговой org.springframework.security.web.DefaultRedirectStrategy и переопределяем логику отправки редиректа.

@RequiredArgsConstructor
public class UnauthorizedRedirectStrategy extends DefaultRedirectStrategy {

    @Override
    public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
            final var redirectUrl = response.encodeRedirectURL(calculateRedirectUrl(request.getContextPath(), url));
            response.setHeader(HttpHeaders.LOCATION, redirectUrl);
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.getWriter().flush();
    }
}

Мы решили логику редиректов переложить на фронт: он может установить все необходимые CORS-заголовки — поэтому мы договорились с фронтом, что если приходит HTTP-статус 401 и заголовок Location не «null» — то они делают редирект.

И далее мы просто используем эту стратегию в oauth2Login

@Bean 
public SecurityFilterChain filterChain(HttpSecurity http,
                                       AuthentikConfigurationProperties properties,
                                       UserAuthenticationFilter authenticationFilter) {
    return http
        .cors(AbstractHttpConfigurer::disable)
        .csrf(AbstractHttpConfigurer::disable)
        .exceptionHandling(
            exceptionConfig -> exceptionConfig
                .authenticationEntryPoint(
                    // in a case of security exception send user to login page
                    new LoginUrlAuthenticationEntryPoint(properties.getUserGroup().getAuthorizationEntryPoint())
                )
        )
        .authorizeHttpRequests(request -> request
            .requestMatchers(ADMIN_REQUEST_MATCHER)
            .authenticated()
            .anyRequest()
            .permitAll()
        )
        .addFilterBefore(authenticationFilter, AnonymousAuthenticationFilter.class)
        .oauth2Login(oauth2 ->
            oauth2.authorizationEndpoint(
                ae -> ae.authorizationRedirectStrategy(new UnauthorizedRedirectStrategy())
            )
        )
        .build();
}

Также нужно заменить эту стратегию в LoginUrlAuthenticationEntryPoint, но, к сожалению, она там захардкожена и нет возможности ее поменять. Поэтому можно либо скопировать класс и заменить стратегию, либо наследоваться от оригинального класса, переопределить метод commence и использовать свою стратегию.

Заключение

В данной статье мы рассмотрели пример простейшей настройки единого входа SSO при помощи authentik и интеграции со Spring Boot приложением. Я надеюсь, что кому-нибудь эта статья оказалась полезна и ему не придется с нуля разбираться в UI и настройках. По заявлениям разработчиков, authentik обладает такой же гибкостью, как и Keycloak (согласно таблице сравнения identity provider на стартовой странице), — так что далее вас ждут увлекательные часы кастомизации и настройки флоу аутентификации под ваши нужды.

Спасибо за внимание!

Автор: TonyDevel

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js