Источник: Nuances of Programming
Spring Security всегда снижал мой интерес к собственным проектам. Как только возникала необходимость выяснить как аутентифицировать пользователей, я сразу начинал испытывать негодование или скуку и просто садился играть в игры. Работая над личным проектом, я хотел добавить в пользовательскую службу JWT аутентификацию, чтобы другие службы могли независимо аутентифицировать запрос. Для осуществления этого я углубился в изучение принципов работы Spring Security и решил, что по этой теме стоит написать статью.
К её завершению у нас будет готовый сервис Spring Webflux, способный создавать аккаунты, авторизовываться в них и совершать аутентифицированные запросы с помощью JWT в качестве пользователя. В процессе чтения вы встретите множество сокращений, вроде Map пользователей вместо фактической базы данных, но такие аспекты можно с лёгкостью экстраполировать из этой статьи.
Начальный проект
Spring Initializr использовался со следующими настройками:
Отсюда я создал базовый REST-контроллер для выполнения регистрации, авторизации и получения пользовательских данных. Вы можете увидеть начало этого сервиса в коммите spring-boot-webflux-jwt-authentication-example#4d149ded77bec3da9ad269e2feb30e81d10d774e.
Базовый пользовательский контроллер:
Подключение к Spring Security
Spring Security предоставляет инструменты для лёгкой аутентификации и авторизации пользовательского доступа к вашему приложению. Просто добавьте его как последовательность фильтров, которые могут выполняться до контроллера приложения. Таким образом вы примените логику безопасности, вроде аутентификации пользователя или полной блокировки доступа. Эти фильтры имеют чёткий порядок, определяемый SecurityWebFilterOrder. Например, процесс аутентификации (подтверждающий, что вы являетесь действительным пользователем системы) должен быть выполнен перед авторизацией (подтверждением наличия у вас доступа к этому ресурсу.)
Настройка фильтров производится через ServerHttpSecurity. Следующий код представляет простую цепочку фильтров безопасности, которая позволяет всем запросам достигать контроллеров и отключает некоторые возможности, не нужные нашему REST API.
Конфигурация безопасности, пропускающая все запросы:
Если вы углубитесь в Spring Security код, то увидите, что метод ServerHttpSecurity#build() используется для применения этих фильтров к SecurityWebFilterChain. Я рекомендую получше здесь покопаться, чтобы максимально понять происходящее.
Если мы проанализируем работу базовой аутентификации, то выявим три основных компонента, которые будут применимы для нашей настраиваемой JWT-аутентификации:
- ServerAuthenticationConverter используется для преобразования запроса в объект аутентификации. Например, в базовой аутентификации он будет считывать заголовок HTTP-запроса Authorization, извлекая имя пользователя и пароль в объект Authentification для дальнейшей обработки.
- ReactiveAuthenticationManager служит для определения, может ли предоставленный объект Authentification быть аутентифицирован. Например, он может взять имя пользователя с паролем, чтобы проверить существуют ли они в базе данных и являются ли верными учётными данными.
- AuthenticationWebFilter — фильтр, используемый для получения запроса и применения к нему всей логики. Если объект Authentification может быть аутентифицирован, то он будет добавлен в ReactiveSecurityContextHolder для использования последующим AuthorizationWebFilter, который определит, имеет ли он доступ к ресурсу.
Создание JWT при успешной авторизации
Первым делом мы обновим конечную точку авторизации, чтобы при успешном входе в систему она генерировала JWT. Для простоты мы используем сохранение JWT в куках, но с точки зрения безопасности есть и лучшие подходы. Эта тема углублённо развёрнута в статье “Полное руководство по управлению JWT во фронтенд клиентах.” .
Мы будем использовать асимметричные криптосистемы, которые будут генерировать приватный ключ для подписи нового токена и публичный ключ для его верификации. Это позволит нам разделить использование публичного ключа с другими микросервисами, чтобы они также могли производить собственные аутентификации, не прибегая к вызову этого пользовательского сервиса.
В качестве библиотеки JWT я собираюсь использовать JJWT, но любая другая эквивалентная библиотека тоже вполне подойдёт.
implementation("io.jsonwebtoken:jjwt-api:0.11.1")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.1")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.1")
Теперь мы можем создать базовый класс JwSigner, использующийся для генерации при запуске пары ключей (keypair), которая, в свою очередь, создаёт JWT-токены.
JwtSigner, ответственный за создание и проверку токенов:
Теперь, когда мы успешно авторизовались, то можем использовать этот JwtSigner, чтобы добавить в ответ куки.
Аутентификация JWT
На данный момент пользователь сможет включить JWT-токен для каждого запроса благодаря дополнительной куки, но текущий код никак это не проверяет. Поэтому следующим шагом будет подключение к Spring Security, чтобы убедиться, что мы авторизуем наши защищённые запросы в отношении JWT-токена.
Мы можем применить аутентификацию. добавив AuthentificationWebFilter в нашу цепочку фильтров. Чтобы это сработало, нам нужно сообщить фильтру, как получить JWT-токен из запроса через пользовательскую функцию ServerAuthenticationConvert.
Эта функция проверяет наличие куки с именем X-Auth и генерирует для неё объект Authentification. Я использовал значение JWT в качестве как имени пользователя, так и пароля, поскольку оно хранит информацию о том и о другом.
Следующим шагом идёт реализация интерфейса ReactAuthenticationManager, который будет использоваться для получения извлечённого объекта Authentication и проверки, является ли он действительным пользователем. Для этого потребуется проанализировать JWT и убедиться, что он допустим и не просрочен.
Здесь используется JwtSigner, созданный ранее для получения JWT и проверки его действительности. Библиотека JJWT обрабатывает случаи, вроде истечения срока действия токена, поэтому, если метод не выбрасывает JwtException, значит токен актуален.
Далее нам нужно создать AuthentificationFilter и добавить его в цепочку.
Теперь, когда мы запустим приложение, любой запрос, не имеющий действительного JWT-токена, будет отвергнут с ответом 401.
Получение пользователя в контроллере
Заключительным шагом будет получение объекта Authentication, чтобы иметь возможность использовать текущего авторизованного пользователя внутри контроллера. Так как Webflux не позволяет использовать ThreadLocals, он хранится в контексте Mono, а не как объект SecurityContext. ReactiveSecurityContextHolder может использоваться для лёгкого получения контекста, но лучше, если он будет автоматически внедряться в сигнатуру функции конечной точки REST.
Для этого мы можем включить Principal в сигнатуру метода UsernamePasswordAuthenticationToken, который мы можем создать заранее.
Получение текущего авторизованного пользователя:
Что насчёт тестирования?
Переходим к написанию теста для рассмотренной нами техники. Поскольку модульные тесты легко проводить самим, я покажу вам пример теста интеграции, который регистрирует пользователя, авторизует его и затем получает детали о владельце пользовательского аккаунта.
Что дальше?
Здесь мы прошли по основам JWT-аутентификации, но при этом очень многое в этом решении было упущено. Например:
- Как разрешить обновление JWT-токена до момента истечения его срока действия?
- Как другие сервисы аутентифицируют JWT?
- Если токен генерируется при запуске, то как это будет работать для мультиузловой системы?
Я надеюсь, что в последующих своих статьях смогу осветить перечисленные моменты на конкретных примерах.
Если же вам интересно итоговое состояние текущего приложения, взгляните на этот коммит.
Читайте также:
Перевод статьи Jaiden Ashmore: JWT Authentication in Spring Boot Webflux.