Волшебные ссылки теперь в Spring Security

Концепция волшебных ссылок (magic link) далеко не нова, однако долгое время у разработчиков не было надежного решения с полноценным комьюнити, которое позволяло бы быстро и просто реализовывать волшебные ссылки в своих приложениях. И вот, наконец, такая фича появилась в spring security. Давайте подробно разберем что у нас получилось, какие проблемы остались, и что планируется реализовать.

Одноразовые токены

Для поддержки волшебных ссылок в фрэймворк были добавлены так называемые «одноразовые токены» (one-time token). Основная идея таких токенов заключается в том, что получить авторизацию по нему можно ровно один раз. Кроме того, уникальный одноразовый токен, как и положено токену, имеет ограниченный срок действия, по умолчанию это 5 минут. Сама ссылка генерируется на основе этого токена и отправляется пользователю, например, на email.

1f927fcae68016f1915baea5e03c27a0.jpg

Пример

Допустим у нас есть ресурс page, к которому мы хотим получить доступ. Начнем с традиционной для spring security формы логина:

82498febd8522f3f39d2f18f7bd5dc1d.jpg

Теперь у нас появилась дополнительная возможность логина — это «Request a One-Time Token». Попробуем ввести нашу гипотетическую почту, например, mail@example.com. Получаем уведомление:

1e3e0a0b9305d5c61069d4b40bd7bca4.jpg

Если пользователь существует, то ему была отправлена волшебная ссылка. В нашем примере это http://localhost:8080/login/ott? token=baecd038-a7d1–4f5b-8c07-bc4f974c5281. При переходе по этой ссылке мы получим окно ввода токена (хотя оно не обязательно):

70deff2ed6a5424c965d56889c00751e.jpg

Нажимаем кнопку «Sign in» и получаем доступ к заветному ресурсу:

043d154233169417f04e2968da60267b.jpg

В лучших традициях spring security все и вся в данном процессе подлежит довольно гибкой кастомизации. Давайте посмотрим, как же нам все это настроить в коде приложения.

Код

Стартер для подключения spring security как был, так и остался spring-boot-starter-security, тут ничего не меняется. Не трудно догадаться, что по большей части все сводится к конфигурированию SecurityFilterChain.

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests((auth) ->
                        auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations())
                                .permitAll()
                                .requestMatchers("/page")
                                .permitAll()
                                .anyRequest()
                                .authenticated()
                )
                .formLogin(Customizer.withDefaults())
                .oneTimeTokenLogin(Customizer.withDefaults())
                .build();
    }

Из нового здесь oneTimeTokenLogin. Это и есть наш процесс логина через одноразовые токены. При подключённом oneTimeTokenLogin, formLogin будет иметь возможность логина через одноразовый токен. Наш ресурс page достаточно отметить, как permitAll. Просто так получить доступ к нему мы все равно не сможем.

Для корректной работы фичи нам также надо определить userDetailsService:

    @Bean
    InMemoryUserDetailsManager userDetailsService() {
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("mail@example.com")
                .password("password")
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

За генерацию токена отвечает новый компонент GenerateOneTimeTokenFilter:

0c4b50f8fb145bde5643833cbfb79905.jpg

Естественно, url по умолчанию /ott/generate можно заменить на любой через настройку oneTimeTokenLogin. Фильтр делегирует генерацию одноразового токена компоненту OneTimeTokenService (метод generate). На данный момент есть две реализации OneTimeTokenService — InMemoryOneTimeTokenService (токены хранятся в хэш-таблице) и JdbcOneTimeTokenService для кластерной среды. В JdbcOneTimeTokenService пока что есть небольшой баг, думаю, что в ближайшую минорную версию spring security этот PR все-таки войдет.

После генерации токена начинает работать OneTimeTokenGenerationSuccessHandler. Реализация этого компонента должна создавать саму волшебную ссылку и, например, оправлять ее на почту пользователю.

@Component
public final class MagicLinkGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler {

    private final MailSender mailSender;

    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    public MagicLinkGenerationSuccessHandler(MailSender mailSender) {
        this.mailSender = mailSender;
    }

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken) throws IOException, ServletException {
        String magicLink = UriComponentsBuilder.fromUriString(UrlUtils.buildFullRequestUrl(request))
                .replacePath(request.getContextPath())
                .replaceQuery(null)
                .fragment(null)
                .path("/login/ott")
                .queryParam("token", oneTimeToken.getTokenValue())
                .toUriString();

        this.mailSender.send(oneTimeToken.getUsername(), "Your Spring Security One Time Token",
                "Use the following link to sign in into the application: " + magicLink);
        this.redirectStrategy.sendRedirect(request, response, "/page");
    }
}

После отправки волшебной ссылки получаем редирект на защищенный ресурс, там мы увидим сообщение о том, что ссылка отправлена на почту.

При переходе по ссылке, в которой присутствует /login/ott и параметр token — сгенерированное значение в OneTimeTokenService, мы попадем в фильтр DefaultOneTimeTokenSubmitPageGeneratingFilter:

f0b6809f5a3d24f7950af5c7d62d10f6.jpg

Этот фильтр предоставляет страницу по умолчанию, где мы видим форму с вводом одноразового токена. На самом деле эта форма нужна лишь для наглядности и какого-либо практического смысла в отображении токена нет. Гораздо более интересна для нас конфигурация AuthenticationFilter — на схеме я указал только специфичные для oneTimeTokenLogin компоненты. Для тех, кто знаком с архитектурой spring security, понять конфигурацию AuthenticationFilter не составит труда: OneTimeTokenAuthenticationConverter извлекает из запроса параметр token и создает объект аутентификации OneTimeTokenAuthenticationToken. ProviderManager делегирует процесс провайдеру OneTimeTokenAuthenticationProvider, тот в свою очередь получает сгенерированный ранее одноразовый токен (объект OneTimeToken) от OneTimeTokenService и завершает процесс — создает аутентифицированный объект OneTimeTokenAuthenticationToken.

Полный пример можно найти в моем репозитории на github. Реактивная реализация фичи также имеется.

Что дальше

Как минимум надо пофиксить баги, которые всплывают по мере использования фичи. Кроме того, возникли вопросы по кастомизации самого одноразового токена, сейчас это можно сделать разве что реализовав свой OneTimeTokenService, что неудобно и несколько громоздко. Решение уже есть — это поддержка GenerateOneTimeTokenRequestResolver, типичный для spring security подход. Также в подвешенном состоянии находится реализация R2dbcReactiveOneTimeTokenService.

В следующих статьях мы обсудим поддержку WebAuthn в spring security, которую так долго ждали и которая наконец появилась. Друзья, подписывайтесь на мой телеграм-канал, там вы найдете много интересного контента по теме InfoSec.

© Habrahabr.ru