Документация Ktor по server-jwt неполна. Если необходимо сделать что-то за рамками «Hello world», придется лезть в исходники и городить костыли. Какой-то консистентности и предсказуемости ждать не стоит, возможно, не обошлось без заговорщиков.

 Говорят, что пользователи фреймворка могут увидеть на картинке слово «Ктор».
Говорят, что пользователи фреймворка могут увидеть на картинке слово «Ктор».

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

Перед чтением ознакомьтесь

Про JWT-аутентификацию можно почитать на хабре.

Терминология Ktor

Principal — клиент. Пример — io.ktor.server.auth.jwt.JWTPrincipal. Почему не User? Клиентом может быть другой сервис, не обязательно пользователь.

HttpAuthHeadersealed class c двумя реализациями: Single и Parameterized.

HttpAuthHeader.Parameterized — хэдер по типу Header: param1="value1", param2="value2". Примеры: digest, HOBA. В статье касаться не будем.

HttpAuthHeader.Single — хэдер по типу Header: Scheme Blob.Два популярных варианта:

  • Basic аутентификация с логином и паролем в base64: Authorization: Basic bG9naW46cGFzc3dvcmQ=.

  • Авторизация с JWT: Authorization: Bearer <headers>.<payload>.<signature>.

Как подключить и настроить JWT аутентификацию

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

implementation("io.ktor:ktor-server-auth-jwt:$ktor_version")

// опционально для отлова ошибок
implementation("io.ktor:ktor-server-status-pages:$ktor_version")

Используем:

routing {
    // Используем одного Authentication.provider
    authenticate("ClientA") { 
        get("/") {
            // 0. Достаем информацию о клиенте (payload токена)
            val client = call.principal()
            call.respondText("Hello World!")
        }
    }
}

Конфигурируем JWT-аутентификацию. Настройки указаны в том порядке, в котором они вызываются в коде:

install(Authentication) {
    jwt("ClientA") { // регистрируем AuthenticationConfig.providers

        // 1. Указываем, как достать заголовок аутентификации
        authHeader {call -> /*...*/ }

        // 2. Цель блока — проверить формат, подпись и срок годности
        verifier { httpAuthHeader -> /*...*/ }

        // 3. Валидация JWT.payload (какое-то поле не того типа или отсутствует)
        validate { credential: JWTCredential -> /*...*/ }

        // 4. Реагируем на ошибку
        challenge { defaultScheme, realm -> /*...*/}
    }
}

// Опционально. 
install(StatusPages) {
    // 5. Сюда прилетают ошибки из некоторых блоков.
    exception { call, e ->
        call.application.log.error("Error", e)
        // тут можем переопределить ответ с call.respond
    }
}

// 6. Где-то в глубинах ктор
// internal val JWTLogger: Logger = LoggerFactory.getLogger("io.ktor.auth.jwt")

Здесь может показаться, что блок challenge сработает, когда в авторизации что-то не так, как и было заявлено.

The challenge function allows you to configure a response to be sent if authentication fails. — Документация Ktor

Если бы было так, то и статья бы не понадобилась.

Обозначим еще один термин, который нам понадобится дальше: УСПЕШНЫЙ СЦЕНАРИЙ аутентификации. Сценарий будет успешным, если authHeader(1) вернет заголовок, verifier(2) не упадет, validate(3) вернет non-null Principal, challenge(4) иexception(5) блоки не запустятся.

Детали конфигурации с одним Authentication.providerм

Все, что не проходит по успешному сценарию, приводит к ответу 401.

Пункт 1, authHeader. Если его не переопределить, будет использоваться заголовок «Authorization». Если из него вернуть null, попадём в challenge(4) блок. Если в нем упасть, то не попадем вchallenge(4) и в логи ничего не напишется (6), но попадем в exception(5). Пример реализации:

authHeader { call ->
   call.request.header("X-Custom-Auth")?.let { parseAuthorizationHeader(it) }
}

Пункт 2, verifier. Блок обязательный — если не переопределить, попадём в challenge (4). Если упасть внутри блока, попадём только в лог (6). Если JWTVerifier вернет ошибку, попадем в challenge (4), но без деталей падения. Детали можно будет найти в логе (6). В exception тоже не попадем. Примеры реализации есть в документации, вот еще один, на основании публичного ключа:

private val keyFactory: KeyFactory = KeyFactory.getInstance("RSA")

fun jwtVerifier(): JWTVerifier {
    val publicKey = File("path/to/public.pem").readText()
        .replace("\n", "")
        .replace("\r", "")
        .replace("-----BEGIN PUBLIC KEY-----", "")
        .replace("-----END PUBLIC KEY-----", "")
    val keySpecX509 = X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyContent))
    val pubKey = keyFactory.generatePublic(keySpecX509) as RSAPublicKey
    val algo = Algorithm.RSA256(pubKey, null)
    return JWT.require(algo).build()
}

verifier { httpAuthHeader ->
    if (httpAuthHeader is HttpAuthHeader.Single) {
        jwtVerifier()
    } else {
        environment.log.error("HttpAuthHeader does not adhere scheme:  ")
        null
    }
}

Пункт 3, validate. Для успешного сценария нужно вернуть non-null объект из блока, который будет в дальнейшем обозначать клиента. Его можно достать внутри запроса через вызов call.principal<T>().

По задумке авторов на ошибках валидации следует не падать, а возвращать null. Если же в блоке выбросится ошибка, мы не попадём ни вchallenge(4), ни вexception(5), но напечатаем лог (6). Пример реализации:

validate { credential: JWTCredential ->
    val scopes = credential.payload.getClaim("scopes").asList(String::class.java)
    if (scopes == null) return@validate null
    JWTPrincipal(credential.payload)
}

Пункт 4, challenge. Блок запускается в следующих случаях:

  • Если authHeader вернет null.

  • Не реализовали verifier (2) (странно, что они вообще дают компилироваться в таком случае).

  • Если JWTVerifier, созданный в verifier(2), выбросит исключение.

  • Если validate(3) вернет null. Если внутри произойдет падение, ошибка придет в exception. Пример реализации:

challenge { defaultScheme: String, realm: String ->
  // Bearer, Ktor Server
    val failures: List<AuthenticationFailedCause> = call.authentication.allFailures
    failures.forEach { call.application.log.error("failure: $it") }
    
    call.respondText(status = HttpStatusCode.Unauthorized) { 
      "Authentication error, but what exactly?" 
    }
}

Всё бы ничего, но вытащить информацию о причине ошибки не так-то просто. AuthenticationFailedCause — просто объект без информации: либо NoCredentials, либо InvalidCredentials. А вот AuthenticationFailedCause.Error вообще никем не возвращается в реализации JWTAuthenticationProvider. Error может прийти при использовании Ktor oauth, что выходит за рамки данной статьи.

Таблица ветвления Ktor jwt auth

Ошибка в блоке ↓

challenge

log

exception

authHeader вернул null

+

authHeader выбросил исключение

+

verifier не задан

+

verifier выбросил исключение

+

JWTVerifier вернул ошибку

+

+

validate вернул null

+

+

validate выбросил исключение

+

challenge выбросил исключение

+

Хотелось бы, что challenge-блок отрабатывал во всех случаях (кроме самого challenge-блока), и я написал подобную реализацию в тестовом проекте (github).

Как определить несколько типов аутентификации

Все вышеописанные правила из предыдущего раздела меняются при определении нескольких типов аутентификации в зависимости от используемой AuthenticationStrategy.

routing {
    authenticate(
        "ClientA",
        "ClientB",
        // стратегия по умолчанию
        strategy = AuthenticationStrategy.FirstSuccessful,
    ) {/*...*/}
}

Имеющиеся 3 стратегии понятны интуитивно:

enum class AuthenticationStrategy { Optional, FirstSuccessful, Required }

AuthenticationStrategy.Optional — если ни один из клиентов не пройдет успешный сценарий аутентификации, то мы не попадем в exception (5) блок и не получим 401, а внутри ручки при вызове call.principal() вернется null.

AuthenticationStrategy.FirstSuccessful — первый из клиентов, который пройдет успешный сценарий, отработает и не позволит отработать больше никому. Jwt-блоки клиентов будут вызваны в том порядке, в котором представлены в методе authenticate. Те, кто успел упасть в любом из блоков, ни на что не повлияют, кроме логов (6), а challenge(4) и exception(5) не запустятся. В call.principal() вернется Principal первого успешного. Если никто не пройдет успешный сценарий, вернется 401 или то, что переопределено на уровне challenge(4) последнего указанного типа аутентификации или общего блока exception(5).

AuthenticationStrategy.Required — все клиенты должны пройти успешный сценарий, иначе получим 401. В call.principal<Any>() вернется principal первого указанного в authenticate клиента («ClientA» в примере выше из этого раздела). Но если вызвать call.principal<ClientBPrincipal>(), то вернется ClientBPrincipal.

Глубокое заглатывание

Ктор поражает гибкостью, достойной самых смелых ассоциаций с подпиленными ребрами.

Представьте, что вы хотите попасть в крартиру. В дом можно попасть либо через парадный подъезд с лифтом, либо через боковой подъезд с лестницей. От обоих подъездов нужны ключи. Внутри дома будет квартира, от которой тоже нужен ключ.

object Auth {
  val scope1 = "ключ от парадного подъезда" 
  val scope2 = "ключ от бокового подъезда"
  val scope3 = "ключ от квартиры"
}

route("building/") { 
  authenticate(Auth.scope1, Auth.scope2, strategy = FirstSuccessful) { 
    route("flat") { 
      authenticate(Auth.scope3, strategy = Required) { 
        get() { /*..*/ }
        post() { /*..*/ }
      }
    }
  }
}

Выглядит так, что в рамках запроса к flat нужны 2 ключа — один от дома (scope1 или scope2), другой от квартиры (scope3).

Но это не так!

Для доступа к ручке достаточно любого из ключей, даже если это не scope3, который помечен как required. Такое поведение происходит от того, что все authenticate равнозначны, а в порядке определения первой шла стратегия FirstSuccessful.

Но что, если мы хотим ожидаемое поведение (scope1 || scope2) && scope3. Как думаете, будут ли работать две реализации ниже?

authenticate("scope3", strategy = AuthenticationStrategy.Required) { 
  route("building/flat") { 
    authenticate("scope1", strategy = AuthenticationStrategy.Required) { 
      get() { /*..*/ }
      post() { /*..*/ }
    }
    authenticate("scope2", strategy = AuthenticationStrategy.Required) { 
      get() { /*..*/ }
      post() { /*..*/ }
    }
  }
}

Вторая реализация:

route("building/flat") {
    authenticate("scope1" ,"scope3", strategy = AuthenticationStrategy.Required) {
        get() { /*..*/ }
        post() { /*..*/ }
    }
    authenticate("scope2" ,"scope3", strategy = AuthenticationStrategy.Required) {
        get() { /*..*/ }
        post() { /*..*/ }
    }
}

Обе будут работать не так, как мы хотим. Пройдут только запросы со scope1 и scope3, т.к. они объявлены первыми. Решить задачу через попытку достать несколько principal и проверить, что есть scope1 или scope2 — тоже не получится. Какие бы стратегии ни использовать, будет только один non-null principal:

route("building/") { 
  authenticate(Auth.scope1, strategy = <FirstSuccessful | Optinal>) { 
    route("flat") { 
      authenticate(Auth.scope2, strategy = <любая стратегия>) { 
        get() { 
            val principal1 = call.principal1<Principal1>()
            val principal2 = call.principal1<Principal2>()
            val alwaysTrue = principal1 == null || principal2 == null
        }
      }
    }
  }
}

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

Не буду больше мучить читателя и сообщу, что на уровне AuthenticationStrategy задачу не решить. Но можно создать отдельный scope4, которые на уровне определения удовлетворится условием (scope1 || scope2) && scope3:

object Scope4Principal

// в блоке install(Authentication)
jwt("scope4") {
    verifier { makeVerfier() }
    validate { credential: JWTCredential ->
        val scopes = credential.payload.getClaim("scopes").asList(String::class.java)
            ?: emptyList()
        if (scopes.contains("scope3") && 
            (scopes.contains("scope1") || scopes.contains("scope2")))
            Scope4Principal
        } else {
            null
        }
    }
}

// в блоке routing
authenticate(Auth.scope4) { 
  route("building/flat") { 
    get() { /*..*/ }
    post() { /*..*/ }
  }
}

Как узнать о причине InvalidCredentials

— Зачем узнавать? — спрашивает читатель?
— Чтобы ускорить разбор проблем, посмотрев в логи. Либо изменить ответ в зависимости от типа ошибки (если безопасники не против).

Казалось бы, это должно быть очевидно и легко сделать, но нет. В блоке challenge(4) есть доступ только до call.authentication.allErrors и call.authentication.allFailures, которые не содержат ничего, кроме одного из двух слов: InvalidCredentials или NoCredentials.

По исходникам видно, что вся полезная информация теряется в блоке catch:

internal suspend fun verifyAndValidate(
    call: ApplicationCall,
    jwtVerifier: JWTVerifier?,
    token: HttpAuthHeader,
    schemes: JWTAuthSchemes,
    validate: suspend ApplicationCall.(JWTCredential) -> Any?
): Any? {
    val jwt = try {
        token.getBlob(schemes)?.let { jwtVerifier?.verify(it) }
    } catch (cause: JWTVerificationException) {
        JWTLogger.debug("JWT verification failed: ${cause.message}", cause)
        // тут и потерялась вся информация о `cause`, но мы можем зацепиться за JWTLogger
        null
    } ?: return null
    /*...*/
}

Если достаточно только логов, то Ktor использует org.slf4j.Logger с именем io.ktor.auth.jwt. Вот пример того, как добавить вывод первых трех строк по ошибкам с jwt от Ktor с logback.xml:

<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>
    <root level="INFO">
        <appender-ref ref="STDOUT"/>
    </root>
    <logger name="org.eclipse.jetty" level="INFO"/>
    <logger name="io.netty" level="INFO"/>


    <!--The below settings are used to show io.ktor.server.auth.jwt issues-->
    <appender name="STDOUT_SHORT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{2} - %msg%n{2}</pattern>
        </encoder>
    </appender>
    <root level="TRACE">
        <appender-ref ref="STDOUT_SHORT"/>
    </root>
    <logger name="io.ktor.auth.jwt" level="TRACE" additivity="false">
        <appender-ref ref="STDOUT_SHORT"/>
    </logger>
</configuration>

Мне этот вариант не нравится, потому что приходится зависеть от деталей реализации. Что если в следующем обновлении переименуют логгер или сообщение от ошибке? Не будем же мы постоянно следить за этим.

Без костылей достать информацию, чтобы изменить ответ, нельзя.

Есть вариант — самостоятельно вызвать JWTVerifier еще раз, чтобы получить ошибку. Решение плохое: придется внутри challengе еще раз доставать заголовок и еще раз вызывать verify. Ладно бы только это, но ведь нет гарантий, что мы попадем в этот challenge (см. таблицу ветвления ktor jwt auth выше).

Иллюзорный вариант — обернуть JWTVerifier в свою реализацию, которая будет запоминать ошибки.

class SpyJwtVerifier(private val delegate: JWTVerifier) : JWTVerifier by delegate {
    var exception: Exception? = null
        private set

    override fun verify(token: String?): DecodedJWT = try {
        delegate.verify(token)
    } catch (e: Exception) {
        exception = e
        throw e
    }
}

jwt("ClientB") {
    val spyVerifier = SpyJwtVerifier(webTokenVerifier())
    verifier { httpAuthHeader -&gt; spyVerifier }
    
    challenge { _, _ ->
        spyVerifier.exception?.let { e -&gt;
            call.application.log.error("Verification error")
            call.respond(HttpStatusCode.Unauthorized, mapOf("message" to e.toString()))
            return@let
        }
        call.respond(
            HttpStatusCode.Unauthorized, 
            mapOf("message" to call.authentication.allFailures.first())
        )
    }
}

Проблема тут в том, что spyVerifier создается один раз. Будет гонка.

Единственное решение проблемы — реализовать свой собственный AuthenticationProvider. Самый просто вариант — взять исходники и поправить две функции onAuthenticate (расширить try-catch блок и вызывать challenge в catch) и ловить ошибки внутри verifyAndValidate. Детали можно посмотреть в тестовом проекте. Таблица ветвления становится более предсказуемой:

Ошибка в блоке ↓

challenge

log

exception

authHeader вернул null

+

+

authHeader выбросил исключение

+

+

verifier не задан

+

+

verifier выбросил исключение

+

+

JWTVerifier вернул ошибку

+

+

validate вернул null

+

+

validate выбросил исключение

+

+

challenge выбросил исключение

+

Вместо заключения

В статье рассмотрели детали настройки и особенности поведения ktor-server-auth. На многие незаданные вопросы можно ответить, вернувшись к таблица ветвления Ktor jwt auth в середине статьи.

Поиграться с библиотекой можно в тестовом проекте (github), там уже есть тесты и поправленная реализация AuthenticationProvider.

Комментарии (0)