feat(security): add internal secret handling and Redis integration for bot events

This commit is contained in:
2026-04-28 09:29:05 +02:00
parent c10a4d7e64
commit 1ab6532b0a
50 changed files with 951 additions and 214 deletions
+3
View File
@@ -45,6 +45,8 @@ dependencies {
}
}
implementation(project(":modules:security"))
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
implementation("io.quarkus:quarkus-rest")
implementation("io.quarkus:quarkus-rest-jackson")
@@ -60,6 +62,7 @@ dependencies {
implementation("io.quarkus:quarkus-micrometer")
implementation("io.quarkus:quarkus-smallrye-openapi")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
@@ -6,8 +6,6 @@ quarkus:
rest-client:
core-service:
url: http://localhost:8080
bot-platform-service:
url: http://localhost:8087
smallrye-openapi:
info-title: NowChess Account Service
path: /openapi
@@ -24,13 +22,24 @@ quarkus:
schema-management:
strategy: drop-and-create
nowchess:
redis:
host: localhost
port: 6379
prefix: nowchess
internal:
secret: ${INTERNAL_SECRET}
"%deployed":
quarkus:
rest-client:
core-service:
url: ${CORE_SERVICE_URL}
bot-platform-service:
url: ${BOT_PLATFORM_SERVICE_URL}
nowchess:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
prefix: ${REDIS_PREFIX:nowchess}
datasource:
db-kind: postgresql
username: ${DB_USER}
@@ -1,20 +0,0 @@
package de.nowchess.account.client
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
@Path("/api/bot")
@RegisterRestClient(configKey = "bot-platform-service")
trait BotPlatformClient:
@POST
@Path("/game/{gameId}/assign")
@Produces(Array(MediaType.APPLICATION_JSON))
def assignBot(
@PathParam("gameId") gameId: String,
@QueryParam("botId") botId: String,
@QueryParam("difficulty") difficulty: Int,
@QueryParam("playingAs") playingAs: String,
@QueryParam("botAccountId") botAccountId: String,
): Unit
@@ -1,7 +1,9 @@
package de.nowchess.account.client
import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class CorePlayerInfo(id: String, displayName: String)
@@ -16,6 +18,7 @@ case class CoreGameResponse(gameId: String)
@Path("/api/board/game")
@RegisterRestClient(configKey = "core-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
trait CoreGameClient:
@POST
@@ -0,0 +1,18 @@
package de.nowchess.account.config
import jakarta.enterprise.context.ApplicationScoped
import org.eclipse.microprofile.config.inject.ConfigProperty
import scala.compiletime.uninitialized
@ApplicationScoped
class RedisConfig:
// scalafix:off DisableSyntax.var
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
var host: String = uninitialized
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
var port: Int = uninitialized
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized
// scalafix:on DisableSyntax.var
@@ -0,0 +1,33 @@
package de.nowchess.account.config
import jakarta.annotation.PreDestroy
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.inject.Inject
import org.redisson.Redisson
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import scala.compiletime.uninitialized
@ApplicationScoped
class RedissonProducer:
// scalafix:off DisableSyntax.var
@Inject var redisConfig: RedisConfig = uninitialized
private var clientOpt: Option[RedissonClient] = None
// scalafix:on DisableSyntax.var
@Produces
@ApplicationScoped
def produceRedissonClient(): RedissonClient =
val config = new Config()
config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
config.useSingleServer().setConnectionMinimumIdleSize(1)
config.useSingleServer().setConnectTimeout(500)
val client = Redisson.create(config)
clientOpt = Some(client)
client
@PreDestroy
def shutdown(): Unit =
clientOpt.foreach(_.shutdown())
@@ -47,6 +47,14 @@ class ChallengeResource:
val userId = UUID.fromString(jwt.getSubject)
Response.ok(challengeService.listForUser(userId)).build()
@GET
@Path("/{id}")
def get(@PathParam("id") id: UUID): Response =
val userId = UUID.fromString(jwt.getSubject)
challengeService.findById(id, userId) match
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
case Left(error) => errorResponse(error)
@POST
@Path("/{id}/accept")
def accept(@PathParam("id") id: UUID): Response =
@@ -1,8 +1,8 @@
package de.nowchess.account.resource
import de.nowchess.account.client.{BotPlatformClient, CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
import de.nowchess.account.service.AccountService
import de.nowchess.account.service.{AccountService, BotEventPublisher}
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
@@ -23,19 +23,13 @@ import java.util.UUID
class OfficialChallengeResource:
// scalafix:off DisableSyntax.var
@Inject
var accountService: AccountService = uninitialized
@Inject
var jwt: JsonWebToken = uninitialized
@Inject var accountService: AccountService = uninitialized
@Inject var jwt: JsonWebToken = uninitialized
@Inject var botEventPublisher: BotEventPublisher = uninitialized
@Inject
@RestClient
var coreGameClient: CoreGameClient = uninitialized
@Inject
@RestClient
var botPlatformClient: BotPlatformClient = uninitialized
// scalafix:on
private val log = Logger.getLogger(classOf[OfficialChallengeResource])
@@ -43,9 +37,9 @@ class OfficialChallengeResource:
@POST
@Path("/{botName}")
def challengeWithDifficulty(
@PathParam("botName") botName: String,
@QueryParam("difficulty") difficulty: Int,
@QueryParam("color") color: String,
@PathParam("botName") botName: String,
@QueryParam("difficulty") difficulty: Int,
@QueryParam("color") color: String,
): Response =
if difficulty < 1000 || difficulty > 2800 then
return Response
@@ -56,10 +50,12 @@ class OfficialChallengeResource:
val playerColor = Option(color).map(_.toLowerCase).getOrElse("random") match
case "white" | "black" | "random" => Option(color).map(_.toLowerCase).getOrElse("random")
case other =>
return Response.status(Response.Status.BAD_REQUEST).entity(ErrorDto(s"Invalid color: $other. Must be white, black or random")).build()
return Response
.status(Response.Status.BAD_REQUEST)
.entity(ErrorDto(s"Invalid color: $other. Must be white, black or random"))
.build()
val userId = UUID.fromString(jwt.getSubject)
val botOpt = accountService.getOfficialBotAccounts().find(_.name == botName)
val userOpt = accountService.findById(userId)
@@ -70,9 +66,9 @@ class OfficialChallengeResource:
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto("User not found")).build()
case (Some(bot), Some(user)) =>
val userIsWhite = playerColor match
case "white" => true
case "black" => false
case _ => scala.util.Random.nextBoolean()
case "white" => true
case "black" => false
case _ => scala.util.Random.nextBoolean()
val (white, black, botColor) =
if userIsWhite then
(CorePlayerInfo(user.id.toString, user.username), CorePlayerInfo(bot.id.toString, bot.name), "black")
@@ -86,9 +82,6 @@ class OfficialChallengeResource:
case Left(err) =>
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(err)).build()
case Right(id) =>
try botPlatformClient.assignBot(id, botName, difficulty, botColor, bot.id.toString)
catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", id)
Response
.status(Response.Status.CREATED)
.entity(OfficialChallengeResponse(id, botName, difficulty))
.build()
try botEventPublisher.publishGameStart(bot.name, id, botColor, difficulty, bot.id.toString)
catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", id)
Response.status(Response.Status.CREATED).entity(OfficialChallengeResponse(id, botName, difficulty)).build()
@@ -0,0 +1,30 @@
package de.nowchess.account.service
import de.nowchess.account.config.RedisConfig
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized
@ApplicationScoped
class BotEventPublisher:
// scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on DisableSyntax.var
def publishGameStart(botId: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
val event = s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","difficulty":$difficulty,"botAccountId":"$botAccountId"}"""
redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").publish(event)
()
def publishChallengeCreated(destUserId: String, challengeId: String, challengerName: String): Unit =
val event = s"""{"type":"challengeCreated","challengeId":"$challengeId","challengerName":"$challengerName"}"""
redisson.getTopic(s"${redisConfig.prefix}:user:$destUserId:events").publish(event)
()
def publishChallengeAccepted(challengerId: String, challengeId: String, gameId: String): Unit =
val event = s"""{"type":"challengeAccepted","challengeId":"$challengeId","gameId":"$gameId"}"""
redisson.getTopic(s"${redisConfig.prefix}:user:$challengerId:events").publish(event)
()
@@ -1,7 +1,6 @@
package de.nowchess.account.service
import de.nowchess.account.client.{
BotPlatformClient,
CoreCreateGameRequest,
CoreGameClient,
CoreGameResponse,
@@ -26,6 +25,7 @@ import org.eclipse.microprofile.rest.client.inject.RestClient
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.UUID
@@ -47,8 +47,7 @@ class ChallengeService:
var coreGameClient: CoreGameClient = uninitialized
@Inject
@RestClient
var botPlatformClient: BotPlatformClient = uninitialized
var botEventPublisher: BotEventPublisher = uninitialized
// scalafix:on
@Transactional
@@ -75,6 +74,8 @@ class ChallengeService:
challenge.createdAt = Instant.now()
challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS)
challengeRepository.persist(challenge)
try botEventPublisher.publishChallengeCreated(destUser.id.toString, challenge.id.toString, challenger.username)
catch case ex: Exception => log.warnf(ex, "Failed to notify dest user for challenge %s", challenge.id)
challenge
@Transactional
@@ -89,6 +90,8 @@ class ChallengeService:
challenge.gameId = gameId
challengeRepository.merge(challenge)
notifyBotIfNeeded(challenge, gameId)
try botEventPublisher.publishChallengeAccepted(challenge.challenger.id.toString, challenge.id.toString, gameId)
catch case ex: Exception => log.warnf(ex, "Failed to notify challenger for game %s", gameId)
challenge
@Transactional
@@ -115,6 +118,16 @@ class ChallengeService:
challengeRepository.merge(challenge)
challenge
def findById(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
for
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
_ <- Either.cond(
challenge.challenger.id == userId || challenge.destUser.id == userId,
(),
ChallengeError.NotAuthorized,
)
yield challenge
def listForUser(userId: UUID): ChallengeListDto =
val incoming = challengeRepository.findActiveByDestUserId(userId).map(toDto)
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
@@ -125,8 +138,8 @@ class ChallengeService:
List(challenge.challenger, challenge.destUser).foreach { user =>
user.getBotAccounts.headOption.foreach { bot =>
val playingAs = if white.id == user.id.toString then "white" else "black"
try botPlatformClient.assignBot(gameId, bot.name, 1400, playingAs, bot.id.toString)
catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", gameId)
try botEventPublisher.publishGameStart(bot.id.toString, gameId, playingAs, 1400, bot.id.toString)
catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", gameId)
}
}