diff --git a/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala index 0dcf188..481d1ba 100644 --- a/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala +++ b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala @@ -1,13 +1,15 @@ package de.nowchess.account.config import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameResponse, CorePlayerInfo, CoreTimeControl} -import de.nowchess.account.domain.{Account, Challenge, ChallengeColor, ChallengeStatus, DeclineReason, TimeControl} +import de.nowchess.account.domain.{UserAccount, BotAccount, OfficialBotAccount, Challenge, ChallengeColor, ChallengeStatus, DeclineReason, TimeControl} import de.nowchess.account.dto.* import io.quarkus.runtime.annotations.RegisterForReflection @RegisterForReflection( targets = Array( - classOf[Account], + classOf[UserAccount], + classOf[BotAccount], + classOf[OfficialBotAccount], classOf[Challenge], classOf[ChallengeColor], classOf[ChallengeStatus], @@ -17,6 +19,13 @@ import io.quarkus.runtime.annotations.RegisterForReflection classOf[TokenResponse], classOf[PlayerInfo], classOf[PublicAccountDto], + classOf[BotAccountDto], + classOf[BotAccountWithTokenDto], + classOf[OfficialBotAccountDto], + classOf[OfficialBotAccountWithTokenDto], + classOf[CreateBotAccountRequest], + classOf[UpdateBotNameRequest], + classOf[RotatedTokenDto], classOf[TimeControlDto], classOf[ChallengeRequest], classOf[ChallengeDto], diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/Account.scala b/modules/account/src/main/scala/de/nowchess/account/domain/Account.scala deleted file mode 100644 index 27c7b3a..0000000 --- a/modules/account/src/main/scala/de/nowchess/account/domain/Account.scala +++ /dev/null @@ -1,29 +0,0 @@ -package de.nowchess.account.domain - -import io.quarkus.hibernate.orm.panache.PanacheEntityBase -import jakarta.persistence.* -import scala.compiletime.uninitialized - -import java.time.Instant -import java.util.UUID - -@Entity -@Table(name = "accounts") -class Account extends PanacheEntityBase: - // scalafix:off DisableSyntax.var - @Id - @GeneratedValue(strategy = GenerationType.UUID) - var id: UUID = uninitialized - - @Column(unique = true, nullable = false) - var username: String = uninitialized - - @Column(unique = true, nullable = false) - var email: String = uninitialized - - var passwordHash: String = uninitialized - - var rating: Int = 1500 - - var createdAt: Instant = uninitialized - // scalafix:on diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala b/modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala index 5b2597f..1f5775c 100644 --- a/modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala +++ b/modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala @@ -17,10 +17,10 @@ class Challenge extends PanacheEntityBase: var id: UUID = uninitialized @ManyToOne - var challenger: Account = uninitialized + var challenger: UserAccount = uninitialized @ManyToOne - var destUser: Account = uninitialized + var destUser: UserAccount = uninitialized @Convert(converter = classOf[ChallengeColorConverter]) @Column(columnDefinition = "varchar(255)") diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/UserAccount.scala b/modules/account/src/main/scala/de/nowchess/account/domain/UserAccount.scala new file mode 100644 index 0000000..8731bdb --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/domain/UserAccount.scala @@ -0,0 +1,81 @@ +package de.nowchess.account.domain + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase +import jakarta.persistence.* +import scala.compiletime.uninitialized +import scala.jdk.CollectionConverters.* + +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "user_accounts") +class UserAccount extends PanacheEntityBase: + // scalafix:off DisableSyntax.var + @Id + @GeneratedValue(strategy = GenerationType.UUID) + var id: UUID = uninitialized + + @Column(unique = true, nullable = false) + var username: String = uninitialized + + @Column(unique = true, nullable = false) + var email: String = uninitialized + + var passwordHash: String = uninitialized + + var rating: Int = 1500 + + var createdAt: Instant = uninitialized + + var banned: Boolean = false + + @OneToMany(mappedBy = "owner", cascade = Array(CascadeType.ALL), orphanRemoval = true) + var botAccounts: java.util.List[BotAccount] = uninitialized + // scalafix:on + + def getBotAccounts: List[BotAccount] = Option(botAccounts).map(_.asScala.toList).getOrElse(Nil) + +@Entity +@Table(name = "bot_accounts") +class BotAccount extends PanacheEntityBase: + // scalafix:off DisableSyntax.var + @Id + @GeneratedValue(strategy = GenerationType.UUID) + var id: UUID = uninitialized + + @Column(nullable = false) + var name: String = uninitialized + + @ManyToOne(optional = false) + @JoinColumn(name = "owner_id", nullable = false) + var owner: UserAccount = uninitialized + + @Column(unique = true, nullable = false, length = 256) + var token: String = uninitialized + + var rating: Int = 1500 + + var createdAt: Instant = uninitialized + + var banned: Boolean = false + // scalafix:on + +@Entity +@Table(name = "official_bot_accounts") +class OfficialBotAccount extends PanacheEntityBase: + // scalafix:off DisableSyntax.var + @Id + @GeneratedValue(strategy = GenerationType.UUID) + var id: UUID = uninitialized + + @Column(nullable = false) + var name: String = uninitialized + + @Column(unique = true, nullable = false, length = 256) + var token: String = uninitialized + + var rating: Int = 1500 + + var createdAt: Instant = uninitialized + // scalafix:on diff --git a/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala b/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala index d6c8bfc..c192672 100644 --- a/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala +++ b/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala @@ -33,3 +33,17 @@ case class DeclineRequest(reason: Option[String]) case class ChallengeListDto(in: List[ChallengeDto], out: List[ChallengeDto]) case class ErrorDto(error: String) + +case class CreateBotAccountRequest(name: String) + +case class UpdateBotNameRequest(name: String) + +case class BotAccountDto(id: String, name: String, rating: Int, createdAt: String) + +case class BotAccountWithTokenDto(id: String, name: String, rating: Int, token: String, createdAt: String) + +case class RotatedTokenDto(token: String) + +case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String) + +case class OfficialBotAccountWithTokenDto(id: String, name: String, rating: Int, token: String, createdAt: String) diff --git a/modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala b/modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala index 48a06f2..e6c8218 100644 --- a/modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala +++ b/modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala @@ -4,8 +4,20 @@ enum AccountError: case UsernameTaken(username: String) case EmailAlreadyRegistered(email: String) case InvalidCredentials + case UserNotFound + case BotNotFound + case BotLimitExceeded + case NotAuthorized + case UserBanned + case BotBanned def message: String = this match case UsernameTaken(u) => s"Username '$u' is already taken" case EmailAlreadyRegistered(e) => s"Email '$e' is already registered" case InvalidCredentials => "Invalid credentials" + case UserNotFound => "User not found" + case BotNotFound => "Bot account not found" + case BotLimitExceeded => "Maximum of 5 bot accounts per user exceeded" + case NotAuthorized => "Not authorized to perform this action" + case UserBanned => "User account is banned" + case BotBanned => "Bot account is banned" diff --git a/modules/account/src/main/scala/de/nowchess/account/filter/AlreadyLoggedInFilter.scala b/modules/account/src/main/scala/de/nowchess/account/filter/AlreadyLoggedInFilter.scala new file mode 100644 index 0000000..04ee512 --- /dev/null +++ b/modules/account/src/main/scala/de/nowchess/account/filter/AlreadyLoggedInFilter.scala @@ -0,0 +1,40 @@ +package de.nowchess.account.filter + +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter} +import jakarta.ws.rs.core.Response +import jakarta.ws.rs.ext.Provider +import org.eclipse.microprofile.jwt.JsonWebToken +import scala.compiletime.uninitialized + +@Provider +@ApplicationScoped +class AlreadyLoggedInFilter extends ContainerRequestFilter: + + @Inject + // scalafix:off DisableSyntax.var + var jwt: JsonWebToken = uninitialized + // scalafix:on + + override def filter(context: ContainerRequestContext): Unit = + val path = context.getUriInfo.getPath + val method = context.getMethod + + if isProtectedEndpoint(path, method) && isAuthenticated then + context.abortWith( + Response + .status(Response.Status.BAD_REQUEST) + .entity("""{"error":"Already logged in"}""") + .build() + ) + + private def isAuthenticated: Boolean = + try jwt.getName != null + catch case _ => false + + private def isProtectedEndpoint(path: String, method: String): Boolean = + (path.contains("/api/account") || path.contains("/account")) && + ((path.endsWith("/api/account") && method == "POST") || + (path.endsWith("/account") && method == "POST") || + (path.contains("/login") && method == "POST")) diff --git a/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala b/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala index 0a4c3fd..1f46877 100644 --- a/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala +++ b/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala @@ -1,6 +1,6 @@ package de.nowchess.account.repository -import de.nowchess.account.domain.Account +import de.nowchess.account.domain.{UserAccount, BotAccount, OfficialBotAccount} import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import jakarta.persistence.EntityManager @@ -9,15 +9,15 @@ import java.util.UUID import scala.jdk.CollectionConverters.* @ApplicationScoped -class AccountRepository: +class UserAccountRepository: @Inject // scalafix:off DisableSyntax.var var em: EntityManager = scala.compiletime.uninitialized // scalafix:on - def findByUsername(username: String): Option[Account] = - em.createQuery("FROM Account WHERE username = :username", classOf[Account]) + def findByUsername(username: String): Option[UserAccount] = + em.createQuery("FROM UserAccount WHERE username = :username", classOf[UserAccount]) .setParameter("username", username) .getResultList .stream() @@ -25,19 +25,83 @@ class AccountRepository: .map(Option(_)) .orElse(None) - def findById(id: UUID): Option[Account] = - Option(em.find(classOf[Account], id)) + def findById(id: UUID): Option[UserAccount] = + Option(em.find(classOf[UserAccount], id)) - def persist(account: Account): Account = + def persist(account: UserAccount): UserAccount = em.persist(account) account - def findByEmail(email: String): Option[Account] = - em.createQuery("FROM Account WHERE email = :email", classOf[Account]) + def findByEmail(email: String): Option[UserAccount] = + em.createQuery("FROM UserAccount WHERE email = :email", classOf[UserAccount]) .setParameter("email", email) .getResultList .asScala .headOption - def findAll(): List[Account] = - em.createQuery("FROM Account", classOf[Account]).getResultList.asScala.toList + def findAll(): List[UserAccount] = + em.createQuery("FROM UserAccount", classOf[UserAccount]).getResultList.asScala.toList + +@ApplicationScoped +class BotAccountRepository: + + @Inject + // scalafix:off DisableSyntax.var + var em: EntityManager = scala.compiletime.uninitialized + // scalafix:on + + def findById(id: UUID): Option[BotAccount] = + Option(em.find(classOf[BotAccount], id)) + + def findByOwner(ownerId: UUID): List[BotAccount] = + em.createQuery("FROM BotAccount WHERE owner.id = :ownerId", classOf[BotAccount]) + .setParameter("ownerId", ownerId) + .getResultList + .asScala + .toList + + def persist(bot: BotAccount): BotAccount = + em.persist(bot) + bot + + def delete(botId: UUID): Unit = + em.find(classOf[BotAccount], botId) match + case bot: BotAccount => em.remove(bot) + case _ => () + + def findByToken(token: String): Option[BotAccount] = + em.createQuery("FROM BotAccount WHERE token = :token", classOf[BotAccount]) + .setParameter("token", token) + .getResultList + .asScala + .headOption + +@ApplicationScoped +class OfficialBotAccountRepository: + + @Inject + // scalafix:off DisableSyntax.var + var em: EntityManager = scala.compiletime.uninitialized + // scalafix:on + + def findById(id: UUID): Option[OfficialBotAccount] = + Option(em.find(classOf[OfficialBotAccount], id)) + + def findAll(): List[OfficialBotAccount] = + em.createQuery("FROM OfficialBotAccount", classOf[OfficialBotAccount]).getResultList.asScala.toList + + def persist(bot: OfficialBotAccount): OfficialBotAccount = + em.persist(bot) + bot + + def delete(botId: UUID): Unit = + em.find(classOf[OfficialBotAccount], botId) match + case bot: OfficialBotAccount => em.remove(bot) + case _ => () + + def findByToken(token: String): Option[OfficialBotAccount] = + em.createQuery("FROM OfficialBotAccount WHERE token = :token", classOf[OfficialBotAccount]) + .setParameter("token", token) + .getResultList + .asScala + .headOption diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala index 92c1994..7507c5d 100644 --- a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala +++ b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala @@ -1,6 +1,6 @@ package de.nowchess.account.resource -import de.nowchess.account.domain.Account +import de.nowchess.account.domain.{UserAccount, BotAccount, OfficialBotAccount} import de.nowchess.account.dto.* import de.nowchess.account.error.AccountError import de.nowchess.account.service.AccountService @@ -42,6 +42,8 @@ class AccountResource: accountService.login(req) match case Right(token) => Response.ok(TokenResponse(token)).build() + case Left(AccountError.UserBanned) => + Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.UserBanned.message)).build() case Left(error) => Response.status(Response.Status.UNAUTHORIZED).entity(ErrorDto(error.message)).build() @@ -61,10 +63,158 @@ class AccountResource: case Some(account) => Response.ok(toPublicDto(account)).build() case None => Response.status(Response.Status.NOT_FOUND).build() - private def toPublicDto(account: Account): PublicAccountDto = + @POST + @Path("/{userId}/ban") + @RolesAllowed(Array("Admin")) + def banUser(@PathParam("userId") userId: String): Response = + accountService.banUser(UUID.fromString(userId)) match + case Right(user) => Response.ok(toPublicDto(user)).build() + case Left(error) => + Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build() + + @POST + @Path("/{userId}/unban") + @RolesAllowed(Array("Admin")) + def unbanUser(@PathParam("userId") userId: String): Response = + accountService.unbanUser(UUID.fromString(userId)) match + case Right(user) => Response.ok(toPublicDto(user)).build() + case Left(error) => + Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build() + + @POST + @Path("/bots") + @RolesAllowed(Array("**")) + def createBotAccount(req: CreateBotAccountRequest): Response = + val ownerId = UUID.fromString(jwt.getSubject) + accountService.createBotAccount(ownerId, req.name) match + case Right(bot) => + Response.status(Response.Status.CREATED).entity(toBotDtoWithToken(bot)).build() + case Left(error) => + val status = error match + case AccountError.BotLimitExceeded => Response.Status.BAD_REQUEST + case _ => Response.Status.INTERNAL_SERVER_ERROR + Response.status(status).entity(ErrorDto(error.message)).build() + + @GET + @Path("/bots") + @RolesAllowed(Array("**")) + def listBotAccounts(): Response = + val ownerId = UUID.fromString(jwt.getSubject) + val bots = accountService.getBotAccounts(ownerId) + Response.ok(bots.map(toBotDto)).build() + + @PUT + @Path("/bots/{botId}") + @RolesAllowed(Array("**")) + def updateBotName(@PathParam("botId") botId: String, req: UpdateBotNameRequest): Response = + val ownerId = UUID.fromString(jwt.getSubject) + accountService.updateBotName(UUID.fromString(botId), ownerId, req.name) match + case Right(bot) => Response.ok(toBotDto(bot)).build() + case Left(AccountError.NotAuthorized) => + Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.NotAuthorized.message)).build() + case Left(error) => + Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build() + + @POST + @Path("/bots/{botId}/rotate-token") + @RolesAllowed(Array("**")) + def rotateBotToken(@PathParam("botId") botId: String): Response = + val ownerId = UUID.fromString(jwt.getSubject) + accountService.rotateBotToken(UUID.fromString(botId), ownerId) match + case Right(bot) => Response.ok(RotatedTokenDto(bot.token)).build() + case Left(AccountError.NotAuthorized) => + Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.NotAuthorized.message)).build() + case Left(error) => + Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build() + + @DELETE + @Path("/bots/{botId}") + @RolesAllowed(Array("**")) + def deleteBotAccount(@PathParam("botId") botId: String): Response = + val ownerId = UUID.fromString(jwt.getSubject) + val botUuid = UUID.fromString(botId) + accountService.getBotAccountWithOwnerCheck(botUuid, ownerId) match + case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(AccountError.BotNotFound.message)).build() + case Some(None) => + Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.NotAuthorized.message)).build() + case Some(Some(_)) => + accountService.deleteBotAccount(botUuid) match + case Right(_) => Response.noContent().build() + case Left(error) => + Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build() + + private def toPublicDto(account: UserAccount): PublicAccountDto = PublicAccountDto( id = account.id.toString, username = account.username, rating = account.rating, createdAt = account.createdAt.toString, ) + + private def toBotDto(bot: BotAccount): BotAccountDto = + BotAccountDto( + id = bot.id.toString, + name = bot.name, + rating = bot.rating, + createdAt = bot.createdAt.toString, + ) + + private def toBotDtoWithToken(bot: BotAccount): BotAccountWithTokenDto = + BotAccountWithTokenDto( + id = bot.id.toString, + name = bot.name, + rating = bot.rating, + token = bot.token, + createdAt = bot.createdAt.toString, + ) + + @GET + @Path("/official-bots") + def getOfficialBots(): Response = + val bots = accountService.getOfficialBotAccounts() + Response.ok(bots.map(toOfficialBotDto)).build() + + @POST + @Path("/official-bots") + @RolesAllowed(Array("Admin")) + def createOfficialBot(req: CreateBotAccountRequest): Response = + accountService.createOfficialBotAccount(req.name) match + case Right(bot) => + Response.status(Response.Status.CREATED).entity(toOfficialBotDtoWithToken(bot)).build() + case Left(error) => + Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build() + + @DELETE + @Path("/official-bots/{botId}") + @RolesAllowed(Array("Admin")) + def deleteOfficialBot(@PathParam("botId") botId: String): Response = + accountService.deleteOfficialBotAccount(UUID.fromString(botId)) match + case Right(_) => Response.noContent().build() + case Left(error) => + Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build() + + @POST + @Path("/official-bots/{botId}/rotate-token") + @RolesAllowed(Array("Admin")) + def rotateOfficialBotToken(@PathParam("botId") botId: String): Response = + accountService.rotateOfficialBotToken(UUID.fromString(botId)) match + case Right(bot) => Response.ok(RotatedTokenDto(bot.token)).build() + case Left(error) => + Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build() + + private def toOfficialBotDto(bot: OfficialBotAccount): OfficialBotAccountDto = + OfficialBotAccountDto( + id = bot.id.toString, + name = bot.name, + rating = bot.rating, + createdAt = bot.createdAt.toString, + ) + + private def toOfficialBotDtoWithToken(bot: OfficialBotAccount): OfficialBotAccountWithTokenDto = + OfficialBotAccountWithTokenDto( + id = bot.id.toString, + name = bot.name, + rating = bot.rating, + token = bot.token, + createdAt = bot.createdAt.toString, + ) diff --git a/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala index 2955989..0fa1f75 100644 --- a/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala +++ b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala @@ -1,9 +1,9 @@ package de.nowchess.account.service -import de.nowchess.account.domain.Account +import de.nowchess.account.domain.{UserAccount, BotAccount, OfficialBotAccount} import de.nowchess.account.dto.{LoginRequest, RegisterRequest} import de.nowchess.account.error.AccountError -import de.nowchess.account.repository.AccountRepository +import de.nowchess.account.repository.{UserAccountRepository, BotAccountRepository, OfficialBotAccountRepository} import io.quarkus.elytron.security.common.BcryptUtil import io.smallrye.jwt.build.Jwt import jakarta.enterprise.context.ApplicationScoped @@ -19,37 +19,164 @@ class AccountService: // scalafix:off DisableSyntax.var @Inject - var accountRepository: AccountRepository = uninitialized + var userAccountRepository: UserAccountRepository = uninitialized + + @Inject + var botAccountRepository: BotAccountRepository = uninitialized + + @Inject + var officialBotAccountRepository: OfficialBotAccountRepository = uninitialized // scalafix:on @Transactional - def register(req: RegisterRequest): Either[AccountError, Account] = - if accountRepository.findByUsername(req.username).isDefined then Left(AccountError.UsernameTaken(req.username)) - else if accountRepository.findByEmail(req.email).isDefined then Left(AccountError.EmailAlreadyRegistered(req.email)) + def register(req: RegisterRequest): Either[AccountError, UserAccount] = + if userAccountRepository.findByUsername(req.username).isDefined then Left(AccountError.UsernameTaken(req.username)) + else if userAccountRepository.findByEmail(req.email).isDefined then Left(AccountError.EmailAlreadyRegistered(req.email)) else - val account = new Account() + val account = new UserAccount() account.username = req.username account.email = req.email account.passwordHash = BcryptUtil.bcryptHash(req.password) account.createdAt = Instant.now() - accountRepository.persist(account) + userAccountRepository.persist(account) Right(account) def login(req: LoginRequest): Either[AccountError, String] = - accountRepository - .findByUsername(req.username) - .filter(a => BcryptUtil.matches(req.password, a.passwordHash)) - .map { account => - Jwt - .issuer("nowchess") - .subject(account.id.toString) - .claim("username", account.username) - .sign() - } - .toRight(AccountError.InvalidCredentials) + userAccountRepository.findByUsername(req.username) match + case None => Left(AccountError.InvalidCredentials) + case Some(account) => + if !BcryptUtil.matches(req.password, account.passwordHash) then Left(AccountError.InvalidCredentials) + else if account.banned then Left(AccountError.UserBanned) + else + Right( + Jwt + .issuer("nowchess") + .subject(account.id.toString) + .claim("username", account.username) + .sign() + ) - def findByUsername(username: String): Option[Account] = - accountRepository.findByUsername(username) + def findByUsername(username: String): Option[UserAccount] = + userAccountRepository.findByUsername(username) - def findById(id: UUID): Option[Account] = - accountRepository.findById(id) + def findById(id: UUID): Option[UserAccount] = + userAccountRepository.findById(id) + + @Transactional + def createBotAccount(ownerId: UUID, botName: String): Either[AccountError, BotAccount] = + userAccountRepository.findById(ownerId) match + case None => Left(AccountError.UserNotFound) + case Some(owner) => + val botAccounts = botAccountRepository.findByOwner(ownerId) + if botAccounts.length >= 5 then Left(AccountError.BotLimitExceeded) + else + val bot = new BotAccount() + bot.name = botName + bot.owner = owner + bot.token = generateBotToken(bot.id) + bot.createdAt = Instant.now() + botAccountRepository.persist(bot) + Right(bot) + + def getBotAccounts(ownerId: UUID): List[BotAccount] = + botAccountRepository.findByOwner(ownerId) + + def getBotAccountWithOwnerCheck(botId: UUID, ownerId: UUID): Option[Option[BotAccount]] = + botAccountRepository.findById(botId) match + case None => Some(None) + case Some(bot) => Some(Option(bot).filter(_.owner.id == ownerId)) + + @Transactional + def deleteBotAccount(botId: UUID): Either[AccountError, Unit] = + botAccountRepository.findById(botId) match + case None => Left(AccountError.BotNotFound) + case Some(_) => + botAccountRepository.delete(botId) + Right(()) + + @Transactional + def updateBotName(botId: UUID, ownerId: UUID, newName: String): Either[AccountError, BotAccount] = + botAccountRepository.findById(botId) match + case None => Left(AccountError.BotNotFound) + case Some(bot) => + if bot.owner.id != ownerId then Left(AccountError.NotAuthorized) + else + bot.name = newName + botAccountRepository.persist(bot) + Right(bot) + + @Transactional + def rotateBotToken(botId: UUID, ownerId: UUID): Either[AccountError, BotAccount] = + botAccountRepository.findById(botId) match + case None => Left(AccountError.BotNotFound) + case Some(bot) => + if bot.owner.id != ownerId then Left(AccountError.NotAuthorized) + else + bot.token = generateBotToken(botId) + botAccountRepository.persist(bot) + Right(bot) + + @Transactional + def createOfficialBotAccount(botName: String): Either[AccountError, OfficialBotAccount] = + val bot = new OfficialBotAccount() + bot.name = botName + bot.token = generateOfficialBotToken(bot.id) + bot.createdAt = Instant.now() + officialBotAccountRepository.persist(bot) + Right(bot) + + def getOfficialBotAccounts(): List[OfficialBotAccount] = + officialBotAccountRepository.findAll() + + @Transactional + def deleteOfficialBotAccount(botId: UUID): Either[AccountError, Unit] = + officialBotAccountRepository.findById(botId) match + case None => Left(AccountError.BotNotFound) + case Some(_) => + officialBotAccountRepository.delete(botId) + Right(()) + + @Transactional + def rotateOfficialBotToken(botId: UUID): Either[AccountError, OfficialBotAccount] = + officialBotAccountRepository.findById(botId) match + case None => Left(AccountError.BotNotFound) + case Some(bot) => + bot.token = generateOfficialBotToken(botId) + officialBotAccountRepository.persist(bot) + Right(bot) + + private def generateBotToken(botId: UUID): String = + Jwt + .issuer("nowchess") + .subject(botId.toString) + .expiresAt(Long.MaxValue) + .claim("type", "bot") + .sign() + + @Transactional + def banUser(userId: UUID): Either[AccountError, UserAccount] = + userAccountRepository.findById(userId) match + case None => Left(AccountError.UserNotFound) + case Some(user) => + user.banned = true + user.botAccounts.forEach(_.banned = true) + userAccountRepository.persist(user) + Right(user) + + @Transactional + def unbanUser(userId: UUID): Either[AccountError, UserAccount] = + userAccountRepository.findById(userId) match + case None => Left(AccountError.UserNotFound) + case Some(user) => + user.banned = false + user.botAccounts.forEach(_.banned = false) + userAccountRepository.persist(user) + Right(user) + + private def generateOfficialBotToken(botId: UUID): String = + Jwt + .issuer("nowchess") + .subject(botId.toString) + .expiresAt(Long.MaxValue) + .claim("type", "official-bot") + .sign() diff --git a/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala b/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala index 1dbb3bd..8bb3921 100644 --- a/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala +++ b/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala @@ -17,7 +17,7 @@ import de.nowchess.account.dto.{ TimeControlDto, } import de.nowchess.account.error.ChallengeError -import de.nowchess.account.repository.{AccountRepository, ChallengeRepository} +import de.nowchess.account.repository.{UserAccountRepository, ChallengeRepository} import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import jakarta.transaction.Transactional @@ -33,7 +33,7 @@ class ChallengeService: // scalafix:off DisableSyntax.var @Inject - var accountRepository: AccountRepository = uninitialized + var userAccountRepository: UserAccountRepository = uninitialized @Inject var challengeRepository: ChallengeRepository = uninitialized @@ -46,8 +46,8 @@ class ChallengeService: @Transactional def create(challengerId: UUID, destUsername: String, req: ChallengeRequest): Either[ChallengeError, Challenge] = for - destUser <- accountRepository.findByUsername(destUsername).toRight(ChallengeError.UserNotFound(destUsername)) - challenger <- accountRepository.findById(challengerId).toRight(ChallengeError.ChallengerNotFound) + destUser <- userAccountRepository.findByUsername(destUsername).toRight(ChallengeError.UserNotFound(destUsername)) + challenger <- userAccountRepository.findById(challengerId).toRight(ChallengeError.ChallengerNotFound) _ <- Either.cond(challenger.id != destUser.id, (), ChallengeError.CannotChallengeSelf) _ <- Either.cond( challengeRepository.findDuplicateChallenge(challengerId, destUser.id).isEmpty,