Прозрачность: иллюзия единой системы. Часть 1


Spring Security всегда снижал мой интерес к собственным проектам. Как только возникала необходимость выяснить как аутентифицировать пользователей, я сразу начинал испытывать негодование или скуку и просто садился играть в игры. Работая над личным проектом, я хотел добавить в пользовательскую службу JWT аутентификацию, чтобы другие службы могли независимо аутентифицировать запрос. Для осуществления этого я углубился в изучение принципов работы Spring Security и решил, что по этой теме стоит написать статью.

К её завершению у нас будет готовый сервис Spring Webflux, способный создавать аккаунты, авторизовываться в них и совершать аутентифицированные запросы с помощью JWT в качестве пользователя. В процессе чтения вы встретите множество сокращений, вроде Map пользователей вместо фактической базы данных, но такие аспекты можно с лёгкостью экстраполировать из этой статьи.

Начальный проект

Spring Initializr использовался со следующими настройками:

  • Kotlin
  • Gradle
  • Spring Boot Webflux
  • Spring Security

Отсюда я создал базовый REST-контроллер для выполнения регистрации, авторизации и получения пользовательских данных. Вы можете увидеть начало этого сервиса в коммите spring-boot-webflux-jwt-authentication-example#4d149ded77bec3da9ad269e2feb30e81d10d774e.

Базовый пользовательский контроллер:

data class User(val email: String) data class UserCredentials(val email: String, val password: String) @RestController @RequestMapping("/user") class UserController { private val users: MutableMap<String, UserCredentials> = mutableMapOf( "[email protected]" to UserCredentials("[email protected]", "pw") ) @PutMapping("/signup") fun signUp(@RequestBody user: UserCredentials): Mono<ResponseEntity<Void>> { users[user.email] = user return Mono.just(ResponseEntity.noContent().build()) } @PostMapping("/login") fun login(@RequestBody user: UserCredentials): Mono<ResponseEntity<Void>> { return Mono.justOrEmpty(users[user.email]) .filter { it.password == user.password } .map { ResponseEntity.noContent().build<Void>() } .switchIfEmpty(Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())) } @GetMapping fun getMyself(): Mono<ResponseEntity<User>> { val emailAddress = "[email protected]" // ultimately this will be obtained from the JWT return Mono.justOrEmpty(users[emailAddress]) .map { ResponseEntity.ok(User(it.email)) } .switchIfEmpty(Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())) } }

Подключение к Spring Security

Spring Security предоставляет инструменты для лёгкой аутентификации и авторизации пользовательского доступа к вашему приложению. Просто добавьте его как последовательность фильтров, которые могут выполняться до контроллера приложения. Таким образом вы примените логику безопасности, вроде аутентификации пользователя или полной блокировки доступа. Эти фильтры имеют чёткий порядок, определяемый SecurityWebFilterOrder. Например, процесс аутентификации (подтверждающий, что вы являетесь действительным пользователем системы) должен быть выполнен перед авторизацией (подтверждением наличия у вас доступа к этому ресурсу.)

Настройка фильтров производится через ServerHttpSecurity. Следующий код представляет простую цепочку фильтров безопасности, которая позволяет всем запросам достигать контроллеров и отключает некоторые возможности, не нужные нашему REST API.

Конфигурация безопасности, пропускающая все запросы:

@Configuration class SecurityConfiguration { @Bean fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { return http.authorizeExchange() .pathMatchers("/**") .permitAll() .and() .httpBasic() .disable() .csrf() .disable() .formLogin() .disable() .logout() .disable() .build() } }

Если вы углубитесь в 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, ответственный за создание и проверку токенов:

import io.jsonwebtoken.Claims import io.jsonwebtoken.Jws import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm import io.jsonwebtoken.security.Keys import org.springframework.stereotype.Service import java.security.KeyPair import java.time.Duration import java.time.Instant import java.util.Date @Service class JwtSigner { val keyPair: KeyPair = Keys.keyPairFor(SignatureAlgorithm.RS256) fun createJwt(userId: String): String { return Jwts.builder() .signWith(keyPair.private, SignatureAlgorithm.RS256) .setSubject(userId) .setIssuer("identity") .setExpiration(Date.from(Instant.now().plus(Duration.ofMinutes(15)))) .setIssuedAt(Date.from(Instant.now())) .compact() } /** * Проверить JWT там, где он будет выбрасывать исключения, не являясь допустимым. */ fun validateJwt(jwt: String): Jws<Claims> { return Jwts.parserBuilder() .setSigningKey(keyPair.public) .build() .parseClaimsJws(jwt) } }

Теперь, когда мы успешно авторизовались, то можем использовать этот JwtSigner, чтобы добавить в ответ куки.

@PostMapping("/login") fun login(@RequestBody user: UserCredentials): Mono<ResponseEntity<Void>> { return Mono.justOrEmpty(users[user.email]) .filter { it.password == user.password } .map { val jwt = jwtSigner.createJwt(it.email) val authCookie = ResponseCookie.fromClientResponse("X-Auth", jwt) .maxAge(3600) .httpOnly(true) .path("/") .secure(false) // в продакшене должно быть true. .build() ResponseEntity.noContent() .header("Set-Cookie", authCookie.toString()) .build<Void>() } .switchIfEmpty(Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())) }

Аутентификация JWT

На данный момент пользователь сможет включить JWT-токен для каждого запроса благодаря дополнительной куки, но текущий код никак это не проверяет. Поэтому следующим шагом будет подключение к Spring Security, чтобы убедиться, что мы авторизуем наши защищённые запросы в отношении JWT-токена.

Мы можем применить аутентификацию. добавив AuthentificationWebFilter в нашу цепочку фильтров. Чтобы это сработало, нам нужно сообщить фильтру, как получить JWT-токен из запроса через пользовательскую функцию ServerAuthenticationConvert.

@Component class JwtServerAuthenticationConverter : ServerAuthenticationConverter { override fun convert(exchange: ServerWebExchange?): Mono<Authentication> { return Mono.justOrEmpty(exchange) .flatMap { Mono.justOrEmpty(it.request.cookies["X-Auth"]) } .filter { it.isNotEmpty() } .map { it[0].value } .map { UsernamePasswordAuthenticationToken(it, it) } } }

Эта функция проверяет наличие куки с именем X-Auth и генерирует для неё объект Authentification. Я использовал значение JWT в качестве как имени пользователя, так и пароля, поскольку оно хранит информацию о том и о другом.

Следующим шагом идёт реализация интерфейса ReactAuthenticationManager, который будет использоваться для получения извлечённого объекта Authentication и проверки, является ли он действительным пользователем. Для этого потребуется проанализировать JWT и убедиться, что он допустим и не просрочен.

@Component class JwtAuthenticationManager(private val jwtSigner: JwtSigner) : ReactiveAuthenticationManager { override fun authenticate(authentication: Authentication): Mono<Authentication> { return Mono.just(authentication) .map { jwtSigner.validateJwt(it.credentials as String) } .onErrorResume { Mono.empty() } .map { jws -> UsernamePasswordAuthenticationToken( jws.body.subject, authentication.credentials as String, mutableListOf(SimpleGrantedAuthority("ROLE_USER")) ) } } }

Здесь используется JwtSigner, созданный ранее для получения JWT и проверки его действительности. Библиотека JJWT обрабатывает случаи, вроде истечения срока действия токена, поэтому, если метод не выбрасывает JwtException, значит токен актуален. 

Далее нам нужно создать AuthentificationFilter и добавить его в цепочку. 

Теперь, когда мы запустим приложение, любой запрос, не имеющий действительного JWT-токена, будет отвергнут с ответом 401.

@Bean fun securityWebFilterChain(http: ServerHttpSecurity, jwtAuthenticationManager: ReactiveAuthenticationManager, jwtAuthenticationConverter: ServerAuthenticationConverter): SecurityWebFilterChain { val authenticationWebFilter = AuthenticationWebFilter(jwtAuthenticationManager) authenticationWebFilter.setServerAuthenticationConverter(jwtAuthenticationConverter) return http.authorizeExchange() .pathMatchers("/user/signup") .permitAll() .pathMatchers("/user/login") .permitAll() .pathMatchers("/user") .authenticated() .and() .addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) .httpBasic() .disable() .csrf() .disable() .formLogin() .disable() .logout() .disable() .build() }

Получение пользователя в контроллере

Заключительным шагом будет получение объекта Authentication, чтобы иметь возможность использовать текущего авторизованного пользователя внутри контроллера. Так как Webflux не позволяет использовать ThreadLocals, он хранится в контексте Mono, а не как объект SecurityContext. ReactiveSecurityContextHolder может использоваться для лёгкого получения контекста, но лучше, если он будет автоматически внедряться в сигнатуру функции конечной точки REST.

Для этого мы можем включить Principal в сигнатуру метода UsernamePasswordAuthenticationToken, который мы можем создать заранее.

Получение текущего авторизованного пользователя:

@GetMapping fun getMyself(principal: Principal): Mono<ResponseEntity<User>> { return Mono.justOrEmpty(users[principal.name]) .map { ResponseEntity.ok(User(it.email)) } .switchIfEmpty(Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())) }

Что насчёт тестирования?

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

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class UserControllerIntegrationTest { @LocalServerPort var serverPort: Int? = null @Test fun `can obtain own user details when logged in`() { // упорядочивание val webClient = WebClient.builder() .baseUrl("http://localhost:${serverPort}") .build() // действие webClient.put() .uri("/user/signup") .bodyValue(UserCredentials("[email protected]", "pw")) .exchange() .block() val loginResponse = webClient.post() .uri("/user/login") .bodyValue(UserCredentials("[email protected]", "pw")) .exchange() .block() ?: throw RuntimeException("Should have gotten a response") val responseCookies = loginResponse.cookies() .map { it.key to it.value.map { cookie -> cookie.value } } .toMap() val response = webClient.get() .uri("/user") .cookies { it.addAll(LinkedMultiValueMap(responseCookies)) } .exchange() .block() // утверждение assertThat(response?.statusCode()).isEqualTo(HttpStatus.OK) assertThat(response?.bodyToFlux(User::class.java)?.blockFirst()).isEqualTo(User("[email protected]")) } }

Что дальше?

Здесь мы прошли по основам JWT-аутентификации, но при этом очень многое в этом решении было упущено. Например:

  • Как разрешить обновление JWT-токена до момента истечения его срока действия?
  • Как другие сервисы аутентифицируют JWT?
  • Если токен генерируется при запуске, то как это будет работать для мультиузловой системы?
  • Я надеюсь, что в последующих своих статьях смогу осветить перечисленные моменты на конкретных примерах.

    Если же вам интересно итоговое состояние текущего приложения, взгляните на этот коммит.


    Перевод статьи


    Поделиться статьей:


    Вернуться к статьям

    Комментарии

      Ничего не найдено.