null

Реализуем самописную аутентификацию с Spring Secuirty OAuth 2 Resource Server

Мотивация

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

Вторая причина, побудившая автора написать эту статью: в рабочем кругу часто ходят упоминания JWT, а также некоторые студенты порываются реализовать Bearer token аутентификацию. То есть вроде бы тема актуальная.

Немного "теории"

JSON Web Tokens - jwt.io

Во-первых, JWT (json web token) - это абстракция, обозначающая, что мы имеем некоторый токен, выступающий в роли ключа, который целиком, или его часть, представляет собой json-объект. Здесь важно подчеркнуть, что JWT именно абстракция, а не конкретная реализация. С точки зрения терминологии можно выделить два основных подтипа JWT:

  • JWS (json web signed token) - это JWT, подтверждение которого осуществляется на основе подписи. При этом содержимое токена, его header и payload, передаются в открытом виде. Вообще говоря, стоит упомянуть для незнакомых с темой формат JWT: header.payload.signature. Здесь точка является ключевым разделителем, а не прихотью автора. Header - json-объект, закодированный Base64, определяющий ключевые параметры токена такие как тип токена, алгоритм подписи. Payload - json-объект, закодированный Base64, так называемая полезная нагрузка токена. Она может содержать либо предопределенные заявки (claims), полный список которых лекго найти в описании стандарта, либо произвольные заявки. При этом ни одна заявка на сегодняшний день не является обязательной к указанию. Signature - результат применения некоторого хеширующего или подписывающего алгоритма к сумме header и payload.
  • JWE (json web encrypted token) - это JWT, по структуре похожий на JWS, однако его payload зашифрован.

При проектировании аутентифкации на основе JWT, обычно используют не один, а пару токенов. Один из которых является токеном доступа, а второй - токеном обновления доступа. Access токен используются для взаимодействия с защищенными ресурсами, может содержать в payload информацию, необходимую для идентификации пользователя и авторизации запроса. Refresh токен используется для обновления доступа. Зачем вообще нужно обновлять доступ? При использовании JWT в случае кражи токена (в случае, когда используется один токен), злоумышленник получит неограниченный доступ к системе на все время жизни токена, который в подобных случаях обладает весьма большим или вовсе бесконечным временем жизни. Поэтому, собственно, и ввели второй токен "обновления доступа", его наличие позволяет сократить время жизни токена доступа до нескольких минут, так как возможно обновить пару токенов при помощи refresh токена без запроса учетных данных пользователя.

Итак, в нашем примере мы рассматриваем следующую ситуацию:

  1. Система состоит целиком из одного сервера или имеет сервис-ориентированную архитектуру, но все части системы пишутся одной компанием, без участия третьих сторон (third-party applications).
  2. Используется схема HTTP авторизации "Bearer" с токеном обновления доступа.
  3. Идентификация пользователя осуществляется на основе id пользователя, зашитом в полезной нагрузки обоих токенов.
  4. Аутентификация пользователя прозвольная, но ее результатом является выдача пары токенов: access и refresh.
  5. Авторизация пользователя осуществляется на основе строковой константы или массива констант, обозначающих права пользователя, зашитых в токен доступа.
  6. Разрешено неограниченное количество одновременных пользовательских сессий.
  7. Нет возможности или необходимости использовать готовые решения, такие как SSO, по любым причинам.

Сервер авторизации

 

Для начала опишем, что необходимо написать на сервере авторизации (к слову, он необязан быть физических или логически отделенным от ресурсного сервера(-ов)).  Очевидно, эндпоинты для аутентификации, регистрация, активации (при необходимости) и тому подобные эндпоинты, которые не являются значимыми в рамках нашего примера. Их реализация может быть абсолютно произвольной. Теперь к тому, что нужно реализовать "по рецепту":

  • В качестве токенов используем JWS с алгоритмом подписи ECDSA256 (использующий два ключа: публичный и приватный). Для генерации и подписи токенов можно использовать библиотеку Nimbus (она же используется внутри Spring Security OAuth 2 Resource Server).
  • Нужно опубликовать набор публичных ключей, используемых для подтверждения подписи токенов, например, в виде открытого эндпоинта. Такой набор ключей принято называть JWKS (json web key set), а публичный ключ, очевидно, JWK.
  • Нужно реализовать эндпоинт продления доступа, который  будет принимать в качестве параметров Refresh токен и отдавать пару новых токенов. Для передачи токена доступа рекомендуется использовать метод POST с передачей параметра в теле запроса.

В токен доступа рекомендуется включить следующую информацию в виде заявок полезной нагрузки:

  • userId - для идентификации пользователя по токену
  • role - для авторизации пользовательского запроса
  • preferenceLang - предпочитаемый язык пользователя (в случае, если используется больше, чем один язык в системе)

Не рекомендуется включать в токен персональную информацию пользователей, такую как email, ФИО и т.д., так как токен будет передаваться в открытом виде (хотя и по HTTPS).

Конкретную реализацию оставляем на откуп разработчика.

Ресурс сервер

Вот здесь у нас уже нет простора для творчества, делаем по шаблону:

  1. Подключаем зависимость на org.springframework.boot:spring-boot-starter-oauth2-resource-server.
  2. Для задания правил безопасности в конфигурации Spring Security используем authorizeHttpRequest. Это поможет избежать гемора с настройкой AuthenticationManager-ов и прочего. Используем максиально простой путь.
  3. Потребуется создать свою собственную имплементацию Authentication, в которой будут храниться userId, role и другие заявки из токена доступа. Рекомендуется наследовать не интерфейс, а абстрактный класс AbstractAuthenticationToken. Опять-таки это проще.
  4. Нужно создать реализацию org.springframework.core.converter.converter.Converter с параметризацией org.springframework.security.oauth2.jwt.Jwt и наш собственный тип, реализующий Authentication.
  5. Указываем в конфиге Spring Security OAuth 2 Resource Server наш конвертер в качестве jwtAuthenticationConverter
  6. Указываем jwk-set-uri и jwk-algorithm для Spring Secuirty OAuth 2 Resource Server.

Вуаля и готово!

Примеры

СММ-стратегия Forbes-Украина, iForum

Конфигурация Spring Security:

@Configuration
class SpringSecurityConfig {
    @Bean
    fun web(http: HttpSecurity): SecurityFilterChain {
        http {
            csrf { disable() }
            authorizeHttpRequests {
                authorize("/api/v1/auth/**", permitAll)
                authorize("/api/v1/user-management/**", hasAuthority(Role.ADMIN.name))
                authorize("/api/v1/users/**", authenticated)

                authorize(anyRequest, denyAll)
            }
            oauth2ResourceServer {
                jwt {
                    jwtAuthenticationConverter = AccessTokenAuthenticationOAuth2Converter()
                }
            }
        }

        return http.build()
    }
}

Класс объекта Authentication

class AccessTokenAuthentication(
    val userId: Long,
    val userPrefLang: Locale,
    authorities: Set<String>,
): AbstractAuthenticationToken(authorities.map { GrantedAuthority { it } }){
    override fun getCredentials(): Any {
        return ""
    }

    override fun getPrincipal(): Any {
        return userId
    }
}

Класс конвертера

class AccessTokenAuthenticationOAuth2Converter: Converter<Jwt, AccessTokenAuthentication> {
    override fun convert(source: Jwt): AccessTokenAuthentication {
        val userId = source.getClaim<Long>("userId")
        val role = source.getClaim<String>("role")
        val prefLang = source.getClaim<String>("prefLang")

        if (userId == null || role == null || prefLang == null) {
            throw IllegalArgumentException("Can't find all required claims in accessToken. Is userId present: ${userId != null}. Is role present: ${role != null}. Is prefLang present: ${prefLang != null}")
        }

        return AccessTokenAuthentication(
            userId = userId,
            userPrefLang = Locale(prefLang),
            authorities = setOf(role)
        ).apply {
            isAuthenticated = true
        }
    }
}

application.yml

security:
  oauth2:
    resourceserver:
      jwt:
        jwk-set-uri: http://localhost:8080/api/v1/auth/jwks
        jws-algorithms: ES256