rewrite authentication flow

This commit is contained in:
2026-08-12 15:42:41 +03:00
parent 70c64a9251
commit d6a0d058ed
9 changed files with 109 additions and 20 deletions
@@ -1,5 +1,6 @@
package ru.shadowsparky.vbox.backend.di package ru.shadowsparky.vbox.backend.di
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import ru.shadowsparky.backend.data.TokenVerifier import ru.shadowsparky.backend.data.TokenVerifier
import ru.shadowsparky.backend.domain.JwtInfo import ru.shadowsparky.backend.domain.JwtInfo
@@ -34,7 +35,8 @@ class RoutingEntryPoint(
@Single @Single
class WebSocketEntryPoint( class WebSocketEntryPoint(
val tokenVerifier: TokenVerifier, val tokenVerifier: TokenVerifier,
val sessionRegistry: SessionRegistry val sessionRegistry: SessionRegistry,
val json: Json
) )
@Single @Single
@@ -1,5 +1,8 @@
package ru.shadowsparky.vbox.backend.presentation 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.Application
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
@@ -11,8 +14,16 @@ import io.ktor.websocket.Frame
import io.ktor.websocket.readText import io.ktor.websocket.readText
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitCancellation 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.SessionRegistry
import ru.shadowsparky.vbox.backend.data.eventLogger
import ru.shadowsparky.vbox.backend.di.WebSocketEntryPoint 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.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
@@ -40,8 +51,7 @@ fun Application.configureWebSocket(socketEntryPoint: WebSocketEntryPoint) = with
webSocket(it) { awaitCancellation() } webSocket(it) { awaitCancellation() }
} }
webSocket(RemoteEventHandler.ON_EVENT) { webSocket(RemoteEventHandler.ON_EVENT) {
val frame = (incoming.receive() as Frame.Text).readText() val userId = incoming.authFlow(json, tokenVerifier, outgoing)
val userId = tokenVerifier.verify(frame).getClaim(USER_ID_ARG).asLong()
val session = SessionRegistry.Writer { text -> outgoing.trySend(Frame.Text(text)) } val session = SessionRegistry.Writer { text -> outgoing.trySend(Frame.Text(text)) }
sessionRegistry.put(userId, session) sessionRegistry.put(userId, session)
val deferred = CompletableDeferred<Unit?>() val deferred = CompletableDeferred<Unit?>()
@@ -54,3 +64,51 @@ fun Application.configureWebSocket(socketEntryPoint: WebSocketEntryPoint) = with
} }
} }
} }
private suspend fun ReceiveChannel<Frame>.receiveTextOrNull(): String? {
val frame = receive()
return if (frame is Frame.Text) frame.readText() else null
}
private suspend fun ReceiveChannel<Frame>.authFlow(
json: Json,
tokenVerifier: TokenVerifier,
sendChannel: SendChannel<Frame>
): Long {
val rawRequest = receiveTextOrNull() ?: throw HttpException(HttpStatusCode.BadRequest)
val request = runCatching { json.decodeFromString<AuthRequest>(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<Frame>.authFlowV2(
initialRequest: AuthRequest,
json: Json,
tokenVerifier: TokenVerifier,
sendChannel: SendChannel<Frame>
): 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<AuthRequest>(nextRaw)
} catch (e: Exception) {
eventLogger.error("unable to verify token", e)
throw HttpException(HttpStatusCode.Unauthorized)
}
}
throw HttpException(HttpStatusCode.Unauthorized)
}
@@ -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.AUTH_JWT_NAME
import ru.shadowsparky.vbox.backend.presentation.obtainUserId import ru.shadowsparky.vbox.backend.presentation.obtainUserId
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
import ru.shadowsparky.vbox.shared.domain.HeathCheck import ru.shadowsparky.vbox.shared.domain.HealthCheck
fun Routing.setupAuthMethods( fun Routing.setupAuthMethods(
routingEntryPoint: RoutingEntryPoint, routingEntryPoint: RoutingEntryPoint,
@@ -40,7 +40,7 @@ fun Routing.setupAuthMethods(
setupTagsRouting(userTagFactory, movieTagFactory) setupTagsRouting(userTagFactory, movieTagFactory)
setupUpdates(updateFetcherFactory) setupUpdates(updateFetcherFactory)
setupChat(chatRepositoryFactory, processUserMessageUseCase, remoteEventHandler) setupChat(chatRepositoryFactory, processUserMessageUseCase, remoteEventHandler)
get(HeathCheck.PATH) { call.respond(HttpStatusCode.OK) } get(HealthCheck.PATH) { call.respond(HttpStatusCode.OK) }
post(AuthTokenRepository.CHANGE_PASS_PATH) { post(AuthTokenRepository.CHANGE_PASS_PATH) {
authEntryPoint.authTokenRepositoryFactory.create(call.obtainUserId()) authEntryPoint.authTokenRepositoryFactory.create(call.obtainUserId())
.changePassword(call.receive()) .changePassword(call.receive())
+1 -1
View File
@@ -7,7 +7,7 @@ plugins {
alias(libs.plugins.convention.ktor.client) alias(libs.plugins.convention.ktor.client)
} }
val appVersion = "1.25.4" val appVersion = "1.25.5"
android { android {
buildFeatures { buildConfig = true } buildFeatures { buildConfig = true }
@@ -3,20 +3,20 @@ package ru.shadowsparky.vbox.shared.data
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.request.get import io.ktor.client.request.get
import org.koin.core.annotation.Factory 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.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
@Factory @Factory
class HeathCheckImpl( class HealthCheckImpl(
private val httpClient: HttpClient, private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository, private val serverConfigurationProvider: ServerConfigurationRepository,
) : HeathCheck { ) : HealthCheck {
override suspend fun check() { override suspend fun check() {
httpClient.get { httpClient.get {
serverConfigurationProvider.getServerConfiguration() serverConfigurationProvider.getServerConfiguration()
.apply(this, HeathCheck.PATH) .apply(this, HealthCheck.PATH)
} }
} }
} }
@@ -9,18 +9,24 @@ import io.ktor.websocket.readText
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import ru.shadowsparky.domain.Log import ru.shadowsparky.domain.Log
import ru.shadowsparky.http.domain.TokenStorage 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.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
@@ -34,21 +40,22 @@ class RemoteEventListenerImpl(
private val httpClient: HttpClient, private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository, private val serverConfigurationRepository: ServerConfigurationRepository,
private val authTokenCache: TokenStorage, private val authTokenCache: TokenStorage,
private val healthCheck: HealthCheck,
private val json: Json, private val json: Json,
private val log: Log private val log: Log
) : RemoteEventListener { ) : RemoteEventListener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) 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 config = serverConfigurationRepository.getServerConfiguration()
val token = authTokenCache.token.first() ?: return@flow
log.d(TAG, "listener started") log.d(TAG, "listener started")
try { try {
httpClient.webSocket( httpClient.webSocket(
method = HttpMethod.Get, method = HttpMethod.Get,
request = { url(config.asWebSocketStr() + "/${RemoteEventHandler.ON_EVENT}") } request = { url(config.asWebSocketStr() + "/${RemoteEventHandler.ON_EVENT}") }
) { ) {
outgoing.send(Frame.Text(token.token)) authV2(token.token, incoming, outgoing)
for (frame in incoming) { for (frame in incoming) {
if (frame is Frame.Text) { if (frame is Frame.Text) {
val text = frame.readText() val text = frame.readText()
@@ -70,6 +77,20 @@ class RemoteEventListenerImpl(
} }
}.retryExponential { true }.shareIn(scope, SharingStarted.WhileSubscribed(500), 0) }.retryExponential { true }.shareIn(scope, SharingStarted.WhileSubscribed(500), 0)
private suspend fun authV2(
token: String,
input: ReceiveChannel<Frame>,
output: SendChannel<Frame>
) {
output.send(Frame.Text(json.encodeToString(AuthRequest(token))))
val rawFrame = (input.receive() as Frame.Text).readText()
val response = json.decodeFromString<AuthResponse>(rawFrame)
if (!response.ok) {
healthCheck.check()
error("Authentication failed")
}
}
private fun <T> Flow<T>.retryExponential( private fun <T> Flow<T>.retryExponential(
maxRetries: Int = Int.MAX_VALUE, maxRetries: Int = Int.MAX_VALUE,
initialDelay: Long = 5000L, initialDelay: Long = 5000L,
@@ -19,7 +19,7 @@ import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.ui.nav.RootComponent import ru.shadowsparky.ui.nav.RootComponent
import ru.shadowsparky.updater.presentation.UpdateComponentFactory import ru.shadowsparky.updater.presentation.UpdateComponentFactory
import ru.shadowsparky.vbox.shared.di.factory.ImageLoaderProvider 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.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.presentation.nav.Route import ru.shadowsparky.vbox.shared.presentation.nav.Route
@@ -28,7 +28,7 @@ class VBoxRootComponent(
componentContext: ComponentContext, componentContext: ComponentContext,
tokenStorage: TokenStorage, tokenStorage: TokenStorage,
private val initRoute: Route, private val initRoute: Route,
private val healthCheck: HeathCheck, private val healthCheck: HealthCheck,
val imageLoaderProvider: ImageLoaderProvider, val imageLoaderProvider: ImageLoaderProvider,
updateComponentFactory: UpdateComponentFactory, updateComponentFactory: UpdateComponentFactory,
private val remoteEventListener: RemoteEventListener, private val remoteEventListener: RemoteEventListener,
@@ -91,7 +91,7 @@ class VBoxRootComponent(
@Factory @Factory
class RootFactory( class RootFactory(
private val tokenStorage: TokenStorage, private val tokenStorage: TokenStorage,
private val heathCheck: HeathCheck, private val healthCheck: HealthCheck,
private val imageLoaderProvider: ImageLoaderProvider, private val imageLoaderProvider: ImageLoaderProvider,
private val updateComponentFactory: UpdateComponentFactory, private val updateComponentFactory: UpdateComponentFactory,
private val remoteEventListener: RemoteEventListener private val remoteEventListener: RemoteEventListener
@@ -111,7 +111,7 @@ class RootFactory(
context, context,
tokenStorage, tokenStorage,
Route.Loading(next = initStack.toSet()), Route.Loading(next = initStack.toSet()),
heathCheck, healthCheck,
imageLoaderProvider, imageLoaderProvider,
updateComponentFactory, updateComponentFactory,
remoteEventListener remoteEventListener
@@ -1,6 +1,6 @@
package ru.shadowsparky.vbox.shared.domain package ru.shadowsparky.vbox.shared.domain
interface HeathCheck { interface HealthCheck {
suspend fun check() suspend fun check()
companion object { companion object {
@@ -45,3 +45,11 @@ sealed interface RemoteEvent {
@SerialName("OnChatUpdate") @SerialName("OnChatUpdate")
data class OnChatUpdate(override val userId: Long) : RemoteEvent 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)