rewrite authentication flow
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+60
-2
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
+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.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())
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
+4
-4
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+26
-5
@@ -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,
|
||||||
|
|||||||
+4
-4
@@ -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
-1
@@ -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 {
|
||||||
+8
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user