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())