From d6a0d058ed61a55fda1d76780b93eecc3c38ddd7 Mon Sep 17 00:00:00 2001 From: Eugene Date: Wed, 12 Aug 2026 15:42:41 +0300 Subject: [PATCH] rewrite authentication flow --- .../vbox/backend/di/EntryPoint.kt | 4 +- .../vbox/backend/presentation/WebSocket.kt | 62 ++++++++++++++++++- .../presentation/routing/AuthRouting.kt | 4 +- apps/vbox/client/androidApp/build.gradle.kts | 2 +- .../{HeathCheckImpl.kt => HealthCheckImpl.kt} | 8 +-- .../shared/data/RemoteEventListenerImpl.kt | 31 ++++++++-- .../shared/presentation/VBoxRootComponent.kt | 8 +-- .../domain/{HeathCheck.kt => HealthCheck.kt} | 2 +- .../vbox/shared/domain/RemoteEventHandler.kt | 8 +++ 9 files changed, 109 insertions(+), 20 deletions(-) rename apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/{HeathCheckImpl.kt => HealthCheckImpl.kt} (79%) rename apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/{HeathCheck.kt => HealthCheck.kt} (85%) diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt index 5adafcb..95a5d7d 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt @@ -1,5 +1,6 @@ package ru.shadowsparky.vbox.backend.di +import kotlinx.serialization.json.Json import org.koin.core.annotation.Single import ru.shadowsparky.backend.data.TokenVerifier import ru.shadowsparky.backend.domain.JwtInfo @@ -34,7 +35,8 @@ class RoutingEntryPoint( @Single class WebSocketEntryPoint( val tokenVerifier: TokenVerifier, - val sessionRegistry: SessionRegistry + val sessionRegistry: SessionRegistry, + val json: Json ) @Single diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/WebSocket.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/WebSocket.kt index 275689f..54e3c13 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/WebSocket.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/WebSocket.kt @@ -1,5 +1,8 @@ package ru.shadowsparky.vbox.backend.presentation +import com.auth0.jwt.exceptions.TokenExpiredException +import com.auth0.jwt.interfaces.DecodedJWT +import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.routing.routing @@ -11,8 +14,16 @@ import io.ktor.websocket.Frame import io.ktor.websocket.readText import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.serialization.json.Json +import ru.shadowsparky.backend.data.TokenVerifier +import ru.shadowsparky.http.domain.HttpException import ru.shadowsparky.vbox.backend.data.SessionRegistry +import ru.shadowsparky.vbox.backend.data.eventLogger import ru.shadowsparky.vbox.backend.di.WebSocketEntryPoint +import ru.shadowsparky.vbox.shared.domain.AuthRequest +import ru.shadowsparky.vbox.shared.domain.AuthResponse import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository @@ -40,8 +51,7 @@ fun Application.configureWebSocket(socketEntryPoint: WebSocketEntryPoint) = with webSocket(it) { awaitCancellation() } } webSocket(RemoteEventHandler.ON_EVENT) { - val frame = (incoming.receive() as Frame.Text).readText() - val userId = tokenVerifier.verify(frame).getClaim(USER_ID_ARG).asLong() + val userId = incoming.authFlow(json, tokenVerifier, outgoing) val session = SessionRegistry.Writer { text -> outgoing.trySend(Frame.Text(text)) } sessionRegistry.put(userId, session) val deferred = CompletableDeferred() @@ -54,3 +64,51 @@ fun Application.configureWebSocket(socketEntryPoint: WebSocketEntryPoint) = with } } } + +private suspend fun ReceiveChannel.receiveTextOrNull(): String? { + val frame = receive() + return if (frame is Frame.Text) frame.readText() else null +} + +private suspend fun ReceiveChannel.authFlow( + json: Json, + tokenVerifier: TokenVerifier, + sendChannel: SendChannel +): Long { + val rawRequest = receiveTextOrNull() ?: throw HttpException(HttpStatusCode.BadRequest) + val request = runCatching { json.decodeFromString(rawRequest) }.getOrNull() + val decodedJwt = if (request == null) { + tokenVerifier.verify(rawRequest) + } else { + authFlowV2(request, json, tokenVerifier, sendChannel) + } + return decodedJwt.getClaim(USER_ID_ARG).asLong() +} + +private const val MAX_ATTEMPTS = 3 + +private suspend fun ReceiveChannel.authFlowV2( + initialRequest: AuthRequest, + json: Json, + tokenVerifier: TokenVerifier, + sendChannel: SendChannel +): DecodedJWT { + var currentRequest = initialRequest + repeat(MAX_ATTEMPTS) { attempt -> + try { + val jwt = tokenVerifier.verify(currentRequest.token) + sendChannel.send(Frame.Text(json.encodeToString(AuthResponse(true)))) + return jwt + } catch (e: TokenExpiredException) { + eventLogger.error("token expired. attempt=$attempt", e) + if (attempt == MAX_ATTEMPTS - 1) return@repeat + sendChannel.send(Frame.Text(json.encodeToString(AuthResponse(false, e.message)))) + val nextRaw = receiveTextOrNull() ?: return@repeat + currentRequest = json.decodeFromString(nextRaw) + } catch (e: Exception) { + eventLogger.error("unable to verify token", e) + throw HttpException(HttpStatusCode.Unauthorized) + } + } + throw HttpException(HttpStatusCode.Unauthorized) +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt index 744804e..6874398 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt @@ -12,7 +12,7 @@ import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint import ru.shadowsparky.vbox.backend.presentation.AUTH_JWT_NAME import ru.shadowsparky.vbox.backend.presentation.obtainUserId import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository -import ru.shadowsparky.vbox.shared.domain.HeathCheck +import ru.shadowsparky.vbox.shared.domain.HealthCheck fun Routing.setupAuthMethods( routingEntryPoint: RoutingEntryPoint, @@ -40,7 +40,7 @@ fun Routing.setupAuthMethods( setupTagsRouting(userTagFactory, movieTagFactory) setupUpdates(updateFetcherFactory) setupChat(chatRepositoryFactory, processUserMessageUseCase, remoteEventHandler) - get(HeathCheck.PATH) { call.respond(HttpStatusCode.OK) } + get(HealthCheck.PATH) { call.respond(HttpStatusCode.OK) } post(AuthTokenRepository.CHANGE_PASS_PATH) { authEntryPoint.authTokenRepositoryFactory.create(call.obtainUserId()) .changePassword(call.receive()) diff --git a/apps/vbox/client/androidApp/build.gradle.kts b/apps/vbox/client/androidApp/build.gradle.kts index f4ad2d2..4ea1994 100644 --- a/apps/vbox/client/androidApp/build.gradle.kts +++ b/apps/vbox/client/androidApp/build.gradle.kts @@ -7,7 +7,7 @@ plugins { alias(libs.plugins.convention.ktor.client) } -val appVersion = "1.25.4" +val appVersion = "1.25.5" android { buildFeatures { buildConfig = true } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/HeathCheckImpl.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/HealthCheckImpl.kt similarity index 79% rename from apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/HeathCheckImpl.kt rename to apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/HealthCheckImpl.kt index dae710a..706a3fa 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/HeathCheckImpl.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/HealthCheckImpl.kt @@ -3,20 +3,20 @@ package ru.shadowsparky.vbox.shared.data import io.ktor.client.HttpClient import io.ktor.client.request.get import org.koin.core.annotation.Factory -import ru.shadowsparky.vbox.shared.domain.HeathCheck +import ru.shadowsparky.vbox.shared.domain.HealthCheck import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository import ru.shadowsparky.vbox.shared.domain.getServerConfiguration @Factory -class HeathCheckImpl( +class HealthCheckImpl( private val httpClient: HttpClient, private val serverConfigurationProvider: ServerConfigurationRepository, -) : HeathCheck { +) : HealthCheck { override suspend fun check() { httpClient.get { serverConfigurationProvider.getServerConfiguration() - .apply(this, HeathCheck.PATH) + .apply(this, HealthCheck.PATH) } } } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteEventListenerImpl.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteEventListenerImpl.kt index b57b1a9..84f5f7a 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteEventListenerImpl.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteEventListenerImpl.kt @@ -9,18 +9,24 @@ import io.ktor.websocket.readText import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.transformLatest import kotlinx.serialization.json.Json import org.koin.core.annotation.Single import ru.shadowsparky.domain.Log import ru.shadowsparky.http.domain.TokenStorage +import ru.shadowsparky.vbox.shared.domain.AuthRequest +import ru.shadowsparky.vbox.shared.domain.AuthResponse +import ru.shadowsparky.vbox.shared.domain.HealthCheck import ru.shadowsparky.vbox.shared.domain.RemoteEvent import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler import ru.shadowsparky.vbox.shared.domain.RemoteEventListener @@ -34,21 +40,22 @@ class RemoteEventListenerImpl( private val httpClient: HttpClient, private val serverConfigurationRepository: ServerConfigurationRepository, private val authTokenCache: TokenStorage, + private val healthCheck: HealthCheck, private val json: Json, private val log: Log ) : RemoteEventListener { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - override val event = flow { + @OptIn(ExperimentalCoroutinesApi::class) + override val event = authTokenCache.token.filterNotNull().transformLatest { token -> val config = serverConfigurationRepository.getServerConfiguration() - val token = authTokenCache.token.first() ?: return@flow log.d(TAG, "listener started") try { httpClient.webSocket( method = HttpMethod.Get, request = { url(config.asWebSocketStr() + "/${RemoteEventHandler.ON_EVENT}") } ) { - outgoing.send(Frame.Text(token.token)) + authV2(token.token, incoming, outgoing) for (frame in incoming) { if (frame is Frame.Text) { val text = frame.readText() @@ -70,6 +77,20 @@ class RemoteEventListenerImpl( } }.retryExponential { true }.shareIn(scope, SharingStarted.WhileSubscribed(500), 0) + private suspend fun authV2( + token: String, + input: ReceiveChannel, + output: SendChannel + ) { + output.send(Frame.Text(json.encodeToString(AuthRequest(token)))) + val rawFrame = (input.receive() as Frame.Text).readText() + val response = json.decodeFromString(rawFrame) + if (!response.ok) { + healthCheck.check() + error("Authentication failed") + } + } + private fun Flow.retryExponential( maxRetries: Int = Int.MAX_VALUE, initialDelay: Long = 5000L, diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/VBoxRootComponent.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/VBoxRootComponent.kt index 04e8eaf..6febfeb 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/VBoxRootComponent.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/VBoxRootComponent.kt @@ -19,7 +19,7 @@ import ru.shadowsparky.http.domain.TokenStorage import ru.shadowsparky.ui.nav.RootComponent import ru.shadowsparky.updater.presentation.UpdateComponentFactory import ru.shadowsparky.vbox.shared.di.factory.ImageLoaderProvider -import ru.shadowsparky.vbox.shared.domain.HeathCheck +import ru.shadowsparky.vbox.shared.domain.HealthCheck import ru.shadowsparky.vbox.shared.domain.RemoteEventListener import ru.shadowsparky.vbox.shared.presentation.nav.Route @@ -28,7 +28,7 @@ class VBoxRootComponent( componentContext: ComponentContext, tokenStorage: TokenStorage, private val initRoute: Route, - private val healthCheck: HeathCheck, + private val healthCheck: HealthCheck, val imageLoaderProvider: ImageLoaderProvider, updateComponentFactory: UpdateComponentFactory, private val remoteEventListener: RemoteEventListener, @@ -91,7 +91,7 @@ class VBoxRootComponent( @Factory class RootFactory( private val tokenStorage: TokenStorage, - private val heathCheck: HeathCheck, + private val healthCheck: HealthCheck, private val imageLoaderProvider: ImageLoaderProvider, private val updateComponentFactory: UpdateComponentFactory, private val remoteEventListener: RemoteEventListener @@ -111,7 +111,7 @@ class RootFactory( context, tokenStorage, Route.Loading(next = initStack.toSet()), - heathCheck, + healthCheck, imageLoaderProvider, updateComponentFactory, remoteEventListener diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HeathCheck.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HealthCheck.kt similarity index 85% rename from apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HeathCheck.kt rename to apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HealthCheck.kt index c6d5f8e..f224dd7 100644 --- a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HeathCheck.kt +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HealthCheck.kt @@ -1,6 +1,6 @@ package ru.shadowsparky.vbox.shared.domain -interface HeathCheck { +interface HealthCheck { suspend fun check() companion object { diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RemoteEventHandler.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RemoteEventHandler.kt index ed59633..2da5e93 100644 --- a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RemoteEventHandler.kt +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RemoteEventHandler.kt @@ -45,3 +45,11 @@ sealed interface RemoteEvent { @SerialName("OnChatUpdate") data class OnChatUpdate(override val userId: Long) : RemoteEvent } + +@Serializable +@SerialName("AuthRequest") +data class AuthRequest(val token: String) + +@Serializable +@SerialName("AuthResponse") +data class AuthResponse(val ok: Boolean, val error: String? = null)