add some stuff to backend base
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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)
|
||||
+90
@@ -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 ?: "Неизвестная ошибка")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -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)
|
||||
}
|
||||
+85
@@ -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)
|
||||
Reference in New Issue
Block a user