rewrite authentication flow
This commit is contained in:
@@ -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
|
||||
|
||||
+60
-2
@@ -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)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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())
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-5
@@ -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,
|
||||
|
||||
+4
-4
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
interface HeathCheck {
|
||||
interface HealthCheck {
|
||||
suspend fun check()
|
||||
|
||||
companion object {
|
||||
+8
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user