Документация Ktor по server-jwt неполна. Если необходимо сделать что-то за рамками «Hello world», придется лезть в исходники и городить костыли. Какой-то консистентности и предсказуемости ждать не стоит, возможно, не обошлось без заговорщиков.
Статья покроет необходимую базу для работы с JWT и убережет от множества подводных камней.
Перед чтением ознакомьтесь
Про JWT-аутентификацию можно почитать на хабре.
Терминология Ktor
Principal — клиент. Пример — io.ktor.server.auth.jwt.JWTPrincipal. Почему не User? Клиентом может быть другой сервис, не обязательно пользователь.
HttpAuthHeader — sealed 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
challengefunction 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 |
|---|---|---|---|
|
|
+ |
– |
– |
|
|
– |
– |
+ |
|
|
+ |
– |
– |
|
|
– |
+ |
– |
|
|
+ |
+ |
– |
|
|
+ |
+ |
– |
|
|
– |
+ |
– |
|
|
– |
– |
+ |
Хотелось бы, что 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 -> spyVerifier } challenge { _, _ -> spyVerifier.exception?.let { e -> 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 |
|---|---|---|---|
|
|
+ |
+ |
– |
|
|
+ |
+ |
– |
|
|
+ |
+ |
– |
|
|
+ |
+ |
– |
|
|
+ |
+ |
– |
|
|
+ |
+ |
– |
|
|
+ |
+ |
– |
|
|
– |
– |
+ |
Вместо заключения
В статье рассмотрели детали настройки и особенности поведения ktor-server-auth. На многие незаданные вопросы можно ответить, вернувшись к таблица ветвления Ktor jwt auth в середине статьи.
Поиграться с библиотекой можно в тестовом проекте (github), там уже есть тесты и поправленная реализация AuthenticationProvider.
ссылка на оригинал статьи https://habr.com/ru/articles/921076/
Добавить комментарий