add some stuff to backend base

This commit is contained in:
2026-09-13 16:32:32 +03:00
parent 688d687d4d
commit 1c573c4366
27 changed files with 440 additions and 313 deletions
+8
View File
@@ -1,8 +1,16 @@
plugins {
alias(libs.plugins.convention.jvm)
alias(libs.plugins.convention.koin)
alias(libs.plugins.ktor)
alias(libs.plugins.convention.serialization)
}
dependencies {
implementation(project(":libs:http-client"))
implementation(libs.java.jwt)
implementation(libs.logback.classic)
implementation(libs.ktor.server.core.jvm)
implementation(libs.ktor.server.auth)
implementation(libs.ktor.server.websockets.jvm)
implementation(libs.ktor.server.auth.jwt)
implementation(libs.ktor.server.status.pages.jvm)
}
@@ -0,0 +1,36 @@
package ru.shadowsparky.backend.data.event
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import org.koin.core.annotation.Single
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import ru.shadowsparky.backend.domain.RemoteEventHandler
val eventLogger: Logger = LoggerFactory.getLogger("events")
@Single
class BackendRemoteEventHandler(
private val registry: SessionRegistry
) : RemoteEventHandler {
override suspend fun notify(eventInfo: JsonElement) {
val userId = eventInfo.jsonObject["userId"]?.jsonPrimitive?.longOrNull
?: error("User id field required")
val writers = registry.get(userId)
if (writers.isNullOrEmpty()) {
eventLogger.info("unable to notify {}. sessions not found, cache {}", eventInfo, registry)
return
}
writers.toList().forEach { writer ->
eventLogger.info("notify[{}]. session {}", eventInfo, writer)
try {
writer.writeText(eventInfo.toString())
} catch (e: Exception) {
eventLogger.error("unable to notify {}. delete session. Reason: {}", writer, e.message)
registry.remove(userId, writer)
}
}
}
}
@@ -0,0 +1,33 @@
package ru.shadowsparky.backend.data.event
import org.koin.core.annotation.Single
import java.util.concurrent.ConcurrentHashMap
private typealias SessionMap = ConcurrentHashMap<Long, MutableSet<SessionRegistry.Writer>>
@Single
class SessionRegistry {
private val sessionMap: SessionMap = ConcurrentHashMap()
fun put(userId: Long, socketSession: Writer) {
eventLogger.info("put[$userId]=$socketSession")
sessionMap.computeIfAbsent(userId) { ConcurrentHashMap.newKeySet() }
.add(socketSession)
}
fun get(userId: Long): Set<Writer>? {
return sessionMap[userId]
}
fun remove(userId: Long, session: Writer) {
eventLogger.info("remove[$userId]=$session")
sessionMap.computeIfPresent(userId) { _, set ->
set.remove(session)
if (set.isEmpty()) null else set
}
}
fun interface Writer {
suspend fun writeText(text: String)
}
}
@@ -0,0 +1,18 @@
package ru.shadowsparky.backend.domain
import kotlinx.serialization.SerializationStrategy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import org.koin.mp.KoinPlatform
interface RemoteEventHandler {
suspend fun notify(eventInfo: JsonElement)
}
suspend fun <T> RemoteEventHandler.notify(
any: T,
serializer: SerializationStrategy<T>,
) {
val json = KoinPlatform.getKoin().get<Json>()
notify(json.encodeToJsonElement(serializer, any))
}
@@ -0,0 +1,6 @@
package ru.shadowsparky.backend.domain
import kotlinx.serialization.Serializable
@Serializable
data class ExceptionInfo(val msg: String)
@@ -0,0 +1,90 @@
package ru.shadowsparky.backend.presentation
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.authentication
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.response.respond
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import ru.shadowsparky.backend.data.TokenVerifier
import ru.shadowsparky.backend.domain.ExceptionInfo
import ru.shadowsparky.backend.domain.INVALID_TOKEN_MSG
import ru.shadowsparky.backend.domain.JwtInfo
import ru.shadowsparky.backend.domain.LOGIN_NAME
import ru.shadowsparky.backend.domain.LoginVerifier
import ru.shadowsparky.backend.domain.USER_ID_ARG
import ru.shadowsparky.backend.domain.VerifyTokenException
import ru.shadowsparky.http.domain.HttpException
const val AUTH_JWT_NAME = "auth-jwt"
val routingLogger: Logger = LoggerFactory.getLogger("routing")
fun ApplicationCall.obtainUserId(): Long {
val principal = authentication.principal<JWTPrincipal>()
?: throw VerifyTokenException(INVALID_TOKEN_MSG)
return principal.payload
.getClaim(USER_ID_ARG)
.asLong()
}
fun Application.configureJwt(
jwtInfo: JwtInfo,
tokenVerifier: TokenVerifier,
loginVerifier: LoginVerifier
) {
install(Authentication) {
jwt(AUTH_JWT_NAME) {
realm = jwtInfo.realm
verifier(tokenVerifier.verifier)
validate { credential ->
val login = credential.payload.getClaim(LOGIN_NAME).asString()
try {
if (login != null) {
loginVerifier.verify(login)
if (credential.payload.expiresAt == null) {
throw VerifyTokenException("Static tokens not supported!")
}
JWTPrincipal(credential.payload)
} else {
null
}
} catch (e: VerifyTokenException) {
routingLogger.error("verify token failed ${e.message}")
null
}
}
challenge { _, _ ->
call.respond(
HttpStatusCode.Unauthorized,
ExceptionInfo(INVALID_TOKEN_MSG)
)
}
}
}
}
fun Application.installStatusPages() {
install(StatusPages) {
exception<HttpException> { call, cause ->
routingLogger.error("http exception occurred. returns ${cause.httpCode}", cause)
call.respond(
status = HttpStatusCode.fromValue(cause.httpCode),
message = ExceptionInfo(cause.message ?: "Неизвестная ошибка")
)
}
exception<Throwable> { call, cause ->
routingLogger.error("error occurred. returns 500...", cause)
call.respond(
status = HttpStatusCode.InternalServerError,
message = ExceptionInfo(cause.message ?: "Неизвестная ошибка")
)
}
}
}
@@ -0,0 +1,105 @@
package ru.shadowsparky.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
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.pingPeriod
import io.ktor.server.websocket.timeout
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.serialization.json.Json
import ru.shadowsparky.backend.data.TokenVerifier
import ru.shadowsparky.backend.data.event.SessionRegistry
import ru.shadowsparky.backend.data.event.eventLogger
import ru.shadowsparky.backend.domain.USER_ID_ARG
import ru.shadowsparky.http.domain.AuthRequest
import ru.shadowsparky.http.domain.AuthResponse
import ru.shadowsparky.http.domain.BadRequestException
import ru.shadowsparky.http.domain.HttpException
import kotlin.time.Duration.Companion.seconds
fun Application.setupWebSocket(
path: String?,
json: Json,
tokenVerifier: TokenVerifier,
sessionRegistry: SessionRegistry
) {
install(WebSockets) {
pingPeriod = (30).seconds
timeout = (30).seconds
maxFrameSize = Long.MAX_VALUE
masking = false
}
path?.let {
routing {
webSocket(path) {
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?>()
try {
outgoing.invokeOnClose { deferred.complete(null) }
deferred.await()
} finally {
sessionRegistry.remove(userId, session)
}
}
}
}
}
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 BadRequestException("Authentication request required")
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)
}
@@ -0,0 +1,85 @@
package ru.shadowsparky.http.data
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Single
import ru.shadowsparky.domain.Log
import ru.shadowsparky.http.domain.AuthRequest
import ru.shadowsparky.http.domain.AuthResponse
import kotlin.math.pow
import kotlin.time.Duration.Companion.milliseconds
@Single
class RemoveEventProcessor(
private val json: Json,
private val log: Log
) {
fun process(
token: String,
session: DefaultClientWebSocketSession,
onTokenInvalid: suspend () -> Unit
): Flow<String> {
val incoming = session.incoming
val outgoing = session.outgoing
return flow {
authV2(token, incoming, outgoing, onTokenInvalid)
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
try {
log.d(TAG, "receive $text")
emit(text)
} catch (e: Exception) {
log.e(TAG, e, "failed to decode message: $text")
}
}
}
}
}
private suspend fun authV2(
token: String,
input: ReceiveChannel<Frame>,
output: SendChannel<Frame>,
onTokenInvalid: suspend () -> Unit
) {
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) {
onTokenInvalid()
error("Authentication failed")
}
}
private companion object {
const val TAG = "RemoveEventProcessor"
}
}
fun <T> Flow<T>.retryExponential(
maxRetries: Int = Int.MAX_VALUE,
initialDelay: Long = 5000L,
maxDelay: Long = 300_000L,
factor: Double = 2.0,
shouldRetry: (Throwable) -> Boolean = { true }
): Flow<T> = retryWhen { cause, attempt ->
if (!shouldRetry(cause) || attempt >= maxRetries || cause is CancellationException) {
false
} else {
val delayTime = (initialDelay * factor.pow(attempt.toDouble()))
.toLong()
.coerceAtMost(maxDelay)
delay(delayTime.milliseconds)
true
}
}
@@ -0,0 +1,12 @@
package ru.shadowsparky.http.domain
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("AuthRequest")
data class AuthRequest(val token: String)
@Serializable
@SerialName("AuthResponse")
data class AuthResponse(val ok: Boolean, val error: String? = null)