feat(security): add internal secret handling and Redis integration for bot events
This commit is contained in:
@@ -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 =
|
||||
|
||||
+18
-25
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user