Привет!
Не так давно передо мной встала задача настроить единый вход (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, который сразу создаст сущность провайдера (метод аутентификации) для нашего приложения.

Шаг 1 - Задаем имя нашего приложения

Шаг 2 - Выбираем тип провайдера
Мы используем OAuth2/OIDC, его и выбираем

Шаг 3 - Настраиваем провайдера

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

Вводим 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

Настраиваем Email Stage

Token expiry — число минут активности ссылки в письме.
Subject — тема письма.
Template — шаблон письма (шаблоны можно создавать свои, дока).
Создаем флоу установки пароля, на который мы будем отправлять пользователя из письма. Делаем это через вкладку Flows And Stages → Flows -> Create.
Создаем flow

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

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

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

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

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

Выбираем User type: Service account
Также создадим токен (Directory -> Tokens and App Passwords) для использования API authentik
Создаем токен

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

Замечу, что на данный момент пользователь не имеет разрешений, чтобы использовать API, хоть у нас и есть токен. Предоставим их созданному пользователю (Directory -> Users). Нажимаем на пользователя (или выбираем кнопку «Редактировать» справа), переходим на вкладку 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. Это будет выглядеть так:
-
Создаете invitation link (ссылку-приглашение) и отправляет ее новому пользователю (можно также завернуть в красивую html). (это может делать ваше приложение или админ напрямую из authentik)
-
Новый пользователь переходит по ссылке и проходит процедуру регистрации.
Ну и, конечно, возможен самый простой способ — это просто сделать на форме логина кнопку «Зарегистрироваться», которая отправляет пользователя на тот же 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 пользователь — добавляем ему группу нашего приложения.
-
Если найдено более 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

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

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 пользователя

Теперь можем запустить приложение, создать пользователя и проверить аутентификацию.
Создаем пользователя запросом
curl --location 'http://localhost:8080/users'
--header 'Content-Type: application/json'
--data-raw '{
"email": "email@gmail.com"
}'
Открываем http://localhost:8025 (на этом порту крутится mock smpt сервис) и видим входящее письмо
Входящее письмо

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

Решение проблем: 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