Регистрация и авторизация в веб-приложении с помощью Spring WebFlux

Подробно описали, как создать регистрацию и авторизацию пользователя при помощи PostgreSQL и Spring WebFlux.

4551

Меня зовут Александр Моруга, я Java разработчик в компании ITentika. В этой статье я расскажу, как сделать веб-приложение с помощью реактивного фреймворка Spring WebFlux.

Желание было собрать воедино задачи, которые часто приходится делать в большинстве веб-приложений: обрабатывать HTTP запросы, обеспечивать разграничение доступа пользователей по ролям и обрабатывать исключения.

Надеюсь, данная статья поможет другим разработчикам сэкономить время — найти всё в одном месте, а не заниматься поиском в Интернет.

Начнём!

Код приложения лежит в репозитории GitHub, в котором находятся 4 коммита, каждый из которых я буду использовать при повествовании.

Сначала нам нужно создать базу данных для хранения информации о пользователях. Поскольку мы создаём реактивное приложение, то и подключение к базе данных тоже должно быть реактивное и его обеспечит R2DBC. Не все базы данных на сегодняшний день поддерживают R2DBC, что ограничивает наш выбор, поэтому выберем PostgreSQL. Устанавливаем базу данных, не забываем прописать путь к psql в переменную PATH (если не пропишется при установке), чтобы в консоли можно было воспользоваться psql и создать базу данных и её пользователя:

Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 1
  1. Для корректного отображения символов кириллицы изменяем кодовую страницу: chcp 1251
  2. Заходим в psql под суперпользователем postgres,

который был создан при установке БД(в данном случае «postgres»):

			psql -U postgres
		
  1. Командой du выводим список пользователей, который есть сейчас
  1. Создаём базу данных chat_db:create database chat_db;
  2. Создаём пользователя chat_user:create user chat_user with encrypted password ‘user_password’;
  3. Делегируем пользователю chat_user привилегии для работы с базой данных chat_db:grant all privileges on database chat_db to chat_user;
  4. Выведем список баз данных, чтобы удостовериться, что пользователь был добавлен к базе: l

Через созданного пользователя будем подключаться к базе данных через R2DBC драйвер в приложении.

Создадим Spring WebFlux приложение с помощью Gradle. Воспользуемся Spring Initializr, чтобы сэкономить время и не создавать структуру проекта вручную:

Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 2

Выбираем тип проекта, язык Java, версию Spring Boot, даём имя нашему приложению, тип выходного файла и версию Java с долгосрочной поддержкой (LTS версия на момент написания статьи — 17).

Добавляем зависимости в проект:

Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 3

Будем использовать:

  1. Spring Reactive Web (spring-boot-starter-webflux) — зависимость для построения Spring приложения на реактивном стеке;
  2. Lombok — для избавления от рутинного кода, придания читабельности;
  3. Spring Security — разграничение доступа к эндпоинтам;
  4. PostgreSQL Driver — драйвер R2DBC и JDBC, который нужен для работы Flyway (пока что не умеет работать через R2DBC);
  5. Thymeleaf — шаблонизатор страниц;
  6. Spring Data R2DBC — стартер для R2DBC, который даёт возможность работать с ReactiveCrudRepository;
  7. Flyway — инструмент для миграций баз данных (при помощи миграций создадим таблицы и заполним их данными).

Нажимаем «GENERATE» внизу страницы и браузер скачивает проект. Распаковываем проект и вручную добавим только зависимость ModelMapper, которым будем пользоваться для преобразования доменных сущностей в DTO (файл build.gradle блок dependencies):

			implementation 'org.modelmapper:modelmapper:3.1.0'
		

Добавим миграции для базы данных в папке resources/db/migration: создадим и заполним таблицы users (здесь будут храниться пользователи) и roles (роли пользователей). Хочу обратить внимание, что в наименовании ролей пользователей Spring Security ожидает увидеть префикс ROLE_, поэтому для пользователя и администратора роли будут ROLE_USER и ROLE_ADMIN (файл миграций V03__populate_roles_table.sql):

			INSERT INTO roles(id, role_name) VALUES
(1, 'ROLE_USER'),
(2, 'ROLE_ADMIN');
		

Пароли для пользователей должны храниться в закодированном виде. Для экономии времени используем онлайн BCrypt кодировщик паролей (для реального проекта лучше воспользоваться методом encode классa BCryptPasswordEncoder):

Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 4

Создадим пользователей admin (пароль password) и user (пароль userpassword) c ролями ROLE_ADMIN и ROLE_USER соответственно (файлы миграций V04__insert_admin.sql и V05__insert_user.sql):

			INSERT INTO users(username, password, role_id) VALUES ('admin', '$2a$12$ISkGJEEPR7I24altoMQNFu42sSpzoIE59Y2tmacCdBjTe47FQL85W', 2);
INSERT INTO users(username, password, role_id) VALUES ('user', '$2a$12$3zd6n5NLCXg3fmGKGnZ9g.SRUFzEfpl01FktM1l.Xe4w9m3SJFPIS', 1);
		

Теперь перейдём к коду приложения. Осуществляем переход на 1-ый коммит 1 Handle error with Advice Controller.

Контроллеры

Обработку HTTP-запросов можно осуществлять с помощью контроллеров — классов, аннотированных @Controller, где аннотации над методами определяют тип и URL запроса (используем @RestController как замену сочетания двух аннотаций @Controller и @ResponseBody):

			@Slf4j
@AllArgsConstructor
@RestController
@RequestMapping("/api/users")
public class UserController {
    private final ModelMapper modelMapper;
    private final UserService userService;
...
    @GetMapping("/{userId}")
    Mono getUser(@PathVariable("userId") Long userId) {
        Mono user = userService.findById(userId).doOnNext((el) ->
                        log.info(Thread.currentThread() + " Getting user "+ el.getUsername() + " " + Instant.now()))
                .flatMap(usr -> Mono.just(modelMapper.map(usr, UserDto.class)));
        log.info(Thread.currentThread() + " Returning from getUser..." + Instant.now());
        return user;
    }
		

Это те контроллеры, которые мы привыкли использовать в Spring MVC приложениях, для WebFlux на выходе они должны выдавать издателей реактивного потока: Flux или Mono. Контроллеры обращаются к сервисам, сервисы — к репозиториям. Flux и Mono испускают данные и только тогда эти данные обрабатываются подписчиками.

Flux — это издатель, который эмитирует от 0 до N элементов.

Mono — это издатель, который эмитирует либо ничего, либо один элемент.

Сервисы

В методах сервисов, обращаясь к методам репозиториев: работаем с цепочкой Mono или Flux и по тем или иным условиям при необходимости выбрасываем исключения. Хочу обратить внимание на метод switchIfEmpty издателя: выражение внутри него выполняется даже в том случае, если издатель будет не пустой (когда не нужно в результат возвращать значение из конструкции switchIfEmpty).

Мы должны сохранять пользователя в базу только в том случае, если его ещё там нет. Если бы внутри конструкции switchIfEmpty мы не обернули вызов userRepository.save(user) в Mono.defer(…), то пользователь бы сохранялся всегда вне зависимости от того: есть он базе или нет.

Лямбда-выражение внутри Mono.defer(…) выполняется только тогда, когда выражение идёт в возвращаемый результат. Для работы Spring Security необходимо предоставить бин, реализующий интерфейс ReactiveUserDetailsService, для чего необходимо переопределить метод Mono findByUsername(String username):

			@Slf4j
@AllArgsConstructor
@Service
public class UserService implements ReactiveUserDetailsService {
...
    @Override
    public Mono findByUsername(String username) {
    return userRepository.findByUsernameWithQuery(username).switchIfEmpty(
                Mono.error(new UsernameNotFoundException(username))
        ).cast(UserDetails.class);
    }
    public Mono addUser(final User user) {
        user.setRoleId(USER_ROLE_ID);
        user.setPassword(encoder.encode(user.getPassword()));
        return userRepository.findByUsernameWithQuery(user.getUsername()).flatMap((el) ->
                Mono.error(new UserAlreadyExistsException(user.getUsername()))
        ).switchIfEmpty(
                Mono.defer(() -> userRepository.save(user))
        );
    }
		

Репозитории

Чтобы создать реактивный репозиторий, нужно создать интерфейс, который расширяет ReactiveCrudRepository. Методы репозитория для выборки данных можно аннотировать @Query так же, как и для JPA репозиториев, чтобы извлекать данные SQL запросом, разница лишь в том, что реактивный репозиторий возвращает Mono или Flux:

			public interface UserRepository extends ReactiveCrudRepository<User, Long> {
    @Query("select u.*,r.role_name as role from users u join roles r on u.role_id = r.id " +
            "where LOWER(u.username)=LOWER(:username)")
    Mono findByUsernameWithQuery(@Param("username") String username);
}
		

Конфигурация доступа к эндпоинтам

Чтобы разграничить доступ по ролям для пользователей необходимо сконфигурировать SecurityWebFilterChain. Мы будем использовать форму логина от Spring, не будем использовать HTTP Basic аутентификацию (отключим её) и укажем с какими ролями и к каким эндпоинтам есть доступ:

			@EnableWebFluxSecurity
public class WebSecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity httpSecurity) {
        return httpSecurity
                .formLogin().and()
                .httpBasic().disable()
                .authorizeExchange()
                .pathMatchers("/", "/login", "/signup").permitAll()
                .pathMatchers(HttpMethod.POST, "/api/users").permitAll()
                .pathMatchers(HttpMethod.GET, "/api/users/**").hasRole("ADMIN")
                .anyExchange().authenticated()
                .and()
                .build();
    }
}
		

Обработка исключений

В Spring WebFlux приложении есть несколько способов обработки исключений. Рассмотрим возможные варианты.

1. Advice controller

Сохранили контроллеры от Spring MVC — сохранили и возможность использовать Advice Controller. Чтобы обрабатывать исключения можно аннотировать класс @AdviceController, а над методами разместить аннотации с типом (или несколькими типами) обрабатываемых исключений (используем @RestAdviceController как замену сочетания двух аннотаций @AdviceController и @ResponseBody). Методы должны возвращать Mono или Flux:

			@Slf4j
@RestControllerAdvice
public class GlobalExceptionController {
    @ExceptionHandler(UserNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Mono handleUserNotFoundException(Exception ex) {
        return Mono.just(new ErrorDto(ex.getMessage()));
    }

    @ExceptionHandler({UserAlreadyExistsException.class})
    @ResponseStatus(HttpStatus.NOT_ACCEPTABLE)
    public Mono handleUserAlreadyExistsException(Exception ex) {
        return Mono.just(new ErrorDto(ex.getMessage()));
    }

}
		

Запустим приложение и перейдём на основную страницу http://localhost:8080/:

Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 5

После того, как пользователь авторизуется, если он обладает ролью ROLE_ADMIN — ему отобразится список все пользователей (станут доступны REST-эндпоинты GET /api/users — получение списка всех пользователей и GET /api/users/{userId} получение информации по id пользователя). На странице Log in пройдём аутентификацию (имя пользователя: admin, пароль: password). Чтобы проверить обработку исключения при получении несуществующего пользователя, выполним GET-запрос с несуществующим userId:

Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 6

Проверим обработку исключения при попытке зарегистрировать уже существующего пользователя: перейдём на страницу Sign up и попытаемся добавить пользователя с именем admin, заведомо зная, что он уже есть в базе:

Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 7

Advice controller обработает исключение и на фронтэнд вернётся класс ErrorDto с сообщением об ошибке, преобразованный в JSON объект и далее отобразится в HTML блоке

На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.
Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 8

Помимо Advice Controller, исключения обрабатываются методом

			Mono handle(ServerWebExchange exchange, Throwable throwable)
		
Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 9

класса DefaultErrorWebExceptionHandler. Поставим breakpoint внутри этого метода и вызовем исключение, которое мы не обрабатываем в Advice Controller: передадим строку в качестве userId:

Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 10

2. Конфигурирование DefaultErrorWebExceptionHandler

Мы можем сконфигурировать возвращаемые данные в ответе при обработке исключения бином DefaultErrorWebExceptionHandler. Например, в ответ можно включить сообщение исключения, прописав конфигурацию в файле application.yml (коммит 2 Handle error with default ErrorWebExceptionHandler):

			server:
  error:
    include-message: always
		

Обратимся к эндпоинту с несуществующим userId и увидим сообщение исключения:

Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 11

3. Расширение абстрактного класса AbstractErrorWebExceptionHandler

Если же просто конфигурации DefaultErrorWebExceptionHandler нам недостаточно, мы можем создать свой собственный ErrorWebExceptionHandler. Для этого необходимо унаследоваться от AbstractErrorWebExceptionHandler. Изменим поведение метода handle, переопределив метод RouterFunction getRoutingFunction(final ErrorAttributes errorAttributes) (коммит 3 Handle error with extending AbstractErrorWebExceptionHandle):

			@Component
public class GlobalErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler {
    public GlobalErrorWebExceptionHandler(ErrorAttributes g, ApplicationContext applicationContext,
                                          ServerCodecConfigurer serverCodecConfigurer) {
        super(g, new WebProperties.Resources(), applicationContext);
        super.setMessageWriters(serverCodecConfigurer.getWriters());
        super.setMessageReaders(serverCodecConfigurer.getReaders());
    }

    @Override
    protected RouterFunction getRoutingFunction(final ErrorAttributes errorAttributes) {
        return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse);
    }

    private Mono renderErrorResponse(ServerRequest request) {
        Map<String, Object> error = getErrorAttributes(request, ErrorAttributeOptions.of(MESSAGE));
        return ServerResponse.status(getHttpStatus(error)).contentType(MediaType.APPLICATION_JSON)
                .body(BodyInserters.fromValue(error));
    }
    private int getHttpStatus(Map<String, Object> errorAttributes) {
        return (int) errorAttributes.get("status");
    }
}
		

Мэппинг HTTP запросов

1. Контроллеры

Разрабатывая Spring WebFlux приложение, мы, как и в Spring MVC приложении, можем пользоваться контроллерами, методы которых должны возвращать Mono или Flux (коммит 1 Handle error with Advice Controller).

2. Функциональные эндпоинты

Поскольку WebFlux придерживается функционального стиля, то для его поддержания при обработке HTTP-запросов были представлены функциональные эндпоинты, где контроль над запросом есть от начала до конца (коммит 4 Routing with Functional Endpoints):

			@AllArgsConstructor
@Configuration
public class RoutesConfig {
    private final ModelMapper modelMapper;
    private final UserService userService;

...

    @Bean
    RouterFunction users() {
        return RouterFunctions.route(
                        GET("/api/users"),
                        (req) -> ServerResponse
                                .ok()
                                .body(userService.findAll().flatMap(usr -> Mono.just(modelMapper.map(usr, UserDto.class))
                                        ),
                                        UserDto.class)
                )
                .and(RouterFunctions.route(
                        GET("/api/users/{userId}"),
                        (req) -> ServerResponse
                                .ok()
                                .body(userService.findById(Long.parseLong(req.pathVariable("userId")))
                                        .flatMap(usr -> Mono.just(modelMapper.map(usr, UserDto.class))), UserDto.class)))
                .and(RouterFunctions.route(
                        POST("/api/users"),
                        (ServerRequest req) -> {
                            Mono user = req.bodyToMono(User.class);
                            return ServerResponse
                                    .ok()
                                    .body(user.flatMap(userService::addUser)
                                            .flatMap(usr -> Mono.just(modelMapper.map(usr, UserDto.class))), UserDto.class);
                        }));
    }
}
		

Всё, что нам нужно — это создать бины, реализующие функциональный интерфейс RouterFunction. Для этого мы прибегли к помощи статического метода RouterFunctions.route(predicate, handlerFunction).

Напоследок: взглянем на неблокированность WebFlux?

Посмотрим, что потоки занимаются задачами, не тратя время на ожидание результата. В WebFlux приложении часть потоков отвечает за обработку HTTP-запросов, высвобождаюсь для следующего запроса сразу после возврата издателя (Mono или Flux) и таким образом каждый поток может обработать больше запросов, не дожидаясь готовности данных. Другая часть потоков занимается обработкой данных, эмитированными издателями. Давайте проверим, что один и тот же поток может обработать входные запросы раньше, чем обработаются возвращаемые данные другими потоками. Для этого число потоков, обрабатывающих входные HTTP-запросы, уменьшим до одного. По умолчанию WebFlux приложение разворачивается на сервере Netty, уменьшим число потоков до одного, сконфигурировав бины для сервера (коммит 1 Handle error with Advice Controller):

			@Configuration
public class WebServerConfig {

    @Bean
    public NioEventLoopGroup nioEventLoopGroup() {
        return new NioEventLoopGroup(1);
    }

    @Bean
    public ReactorResourceFactory reactorResourceFactory(NioEventLoopGroup eventLoopGroup) {
        ReactorResourceFactory f = new ReactorResourceFactory();
        f.setLoopResources(b -> eventLoopGroup);
        f.setUseGlobalResources(false);
        return f;

    }
}
		

В контроллере будем писать в лог перед выходом из метода контроллера и когда издатель эмитировал данные (метод doOnNext):

			@Slf4j
@AllArgsConstructor
@RestController
@RequestMapping("/api/users")
public class UserController {
    private final ModelMapper modelMapper;
    private final UserService userService;

    @GetMapping
    Flux getUsers() {
        Flux users = userService.findAll().doOnNext((el) ->
                        log.info(Thread.currentThread() + " Getting users " +                     el.getUsername() + " " + Instant.now()))
                .flatMap(usr -> Mono.just(modelMapper.map(usr, UserDto.class)));
        log.info(Thread.currentThread() + " Returning from getUsers..." + Instant.now());
        return users;
    }
		

Чтобы одновременно послать 2 HTTP-запроса, воспользуемся Apache JMeter и плагином для параллельного выполнения запросов.

Выполним GET-зарос на страницу логина (чтобы получить значение CSRF), POST-запрос для аутентификации (c значением CSRF заголовке) и затем параллельно два GET-запроса получения всех пользователей:

Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 12

Посмотрим в логи приложения у увидим, что поток Thread[nioEventLoopGroup-2-1,10,main] обрабатывает HTTP-запросы раньше, чем обрабатываются возвращаемые данные (поток Thread[reactor-tcp-nio-2,10,main]):

Регистрация и авторизация в веб-приложении с помощью Spring WebFlux 13

Заключение

В этой статье мы рассмотрели различные способы мэппинга HTTP-запросов и обработки исключений, но разрабатывая WebFlux-приложение, идеологически следует применять функциональные эндпоинты и ErrorWebExceptionHandler ?

Источники

  • https://www.baeldung.com/spring-webflux
  • https://www.baeldung.com/spring-webflux-errors
  • https://www.baeldung.com/java-reactive-systems
  • https://www.baeldung.com/spring-webflux-concurrency
  • https://www.baeldung.com/spring-5-functional-web
  • https://www.baeldung.com/java-mono-defer
  • https://piotrminkowski.com/2020/03/30/a-deep-dive-into-spring-webflux-threading-model/
  • https://www.redline13.com/blog/2019/04/the-jmeter-plugin-parallel-controller-is-very-helpful-for-a-number-of-different-testing-scenarios/
  • https://www.blazemeter.com/blog/jmeter-regular-expressions-extractor
  • https://medium.com/effective-developers/10-steps-to-run-first-performance-test-with-apache-jmeter-52867c12b0a4
  • https://nickolasfisher.com/blog/How-to-Configure-Reactive-Netty-in-Spring-Boot-in-Depth
  • https://www.youtube.com/watch?v=gu7tJJJcDRo
  • https://docs.spring.io/spring-security/site/docs/5.0.x/reference/html/test-webflux.html
  • https://www.baeldung.com/reactive-streams-step-verifier-test-publisher
  • https://howtodoinjava.com/spring-webflux/webfluxtest-with-webtestclient/
  • https://medium.com/@BPandey/writing-unit-test-in-reactive-spring-boot-application-32b8878e2f57
4551