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
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
@@ -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<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.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())
+1 -1
View File
@@ -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 }
@@ -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)
}
}
}
@@ -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<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(
maxRetries: Int = Int.MAX_VALUE,
initialDelay: Long = 5000L,
@@ -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
@@ -1,6 +1,6 @@
package ru.shadowsparky.vbox.shared.domain
interface HeathCheck {
interface HealthCheck {
suspend fun check()
companion object {
@@ -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)