diff --git a/.gitignore b/.gitignore index aa06509..c0c2529 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,4 @@ target /knockoutwhist/ /knockoutwhistweb/.g8/ /knockoutwhistweb/.bsp/ +/currentSnapshot.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cc01af --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ + + + + + + +## User Password Protection + +All the User Passwords are encrypted using Argon2. \ No newline at end of file diff --git a/bruno/KnockOutWhist/Game/Create Game.bru b/bruno/KnockOutWhist/Game/Create Game.bru new file mode 100644 index 0000000..2402878 --- /dev/null +++ b/bruno/KnockOutWhist/Game/Create Game.bru @@ -0,0 +1,16 @@ +meta { + name: Create Game + type: http + seq: 1 +} + +post { + url: {{host}}/createGame + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/KnockOutWhist/Game/Get Game.bru b/bruno/KnockOutWhist/Game/Get Game.bru new file mode 100644 index 0000000..6b6d6af --- /dev/null +++ b/bruno/KnockOutWhist/Game/Get Game.bru @@ -0,0 +1,20 @@ +meta { + name: Get Game + type: http + seq: 2 +} + +get { + url: {{host}}/game/:id + body: none + auth: inherit +} + +params:path { + id: uZDNZA +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/KnockOutWhist/Game/Start Game.bru b/bruno/KnockOutWhist/Game/Start Game.bru new file mode 100644 index 0000000..cc231bf --- /dev/null +++ b/bruno/KnockOutWhist/Game/Start Game.bru @@ -0,0 +1,20 @@ +meta { + name: Start Game + type: http + seq: 3 +} + +post { + url: {{host}}/game/:id/start + body: none + auth: inherit +} + +params:path { + id: uZDNZA +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/KnockOutWhist/Game/folder.bru b/bruno/KnockOutWhist/Game/folder.bru new file mode 100644 index 0000000..a279bdf --- /dev/null +++ b/bruno/KnockOutWhist/Game/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Game + seq: 3 +} + +auth { + mode: inherit +} diff --git a/bruno/KnockOutWhist/Login.bru b/bruno/KnockOutWhist/Login.bru new file mode 100644 index 0000000..2aecc28 --- /dev/null +++ b/bruno/KnockOutWhist/Login.bru @@ -0,0 +1,26 @@ +meta { + name: Login + type: http + seq: 2 +} + +post { + url: {{host}}/login + body: formUrlEncoded + auth: inherit +} + +body:form-urlencoded { + username: Janis + password: password123 +} + +body:multipart-form { + username: Janis + password: password123 +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/KnockOutWhist/bruno.json b/bruno/KnockOutWhist/bruno.json new file mode 100644 index 0000000..c687c5e --- /dev/null +++ b/bruno/KnockOutWhist/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "KnockOutWhist", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/bruno/KnockOutWhist/collection.bru b/bruno/KnockOutWhist/collection.bru new file mode 100644 index 0000000..e69de29 diff --git a/bruno/KnockOutWhist/environments/Local.bru b/bruno/KnockOutWhist/environments/Local.bru new file mode 100644 index 0000000..b22b9b6 --- /dev/null +++ b/bruno/KnockOutWhist/environments/Local.bru @@ -0,0 +1,3 @@ +vars { + host: http://localhost:9000 +} diff --git a/build.sbt b/build.sbt index 535154e..50d5526 100644 --- a/build.sbt +++ b/build.sbt @@ -37,7 +37,10 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb")) .dependsOn(knockoutwhist % "compile->compile;test->test") .settings( commonSettings, - libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test + libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test, + libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12", + libraryDependencies += "com.auth0" % "java-jwt" % "4.3.0", + libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.2" ) lazy val root = (project in file(".")) diff --git a/knockoutwhist b/knockoutwhist index fbc0ea2..e0e45c4 160000 --- a/knockoutwhist +++ b/knockoutwhist @@ -1 +1 @@ -Subproject commit fbc0ea2277596e2a2d29125b5f9a84213336dc18 +Subproject commit e0e45c4b431fff6740e38a59906f5e217fcd801f diff --git a/knockoutwhistweb/app/assets/stylesheets/login.less b/knockoutwhistweb/app/assets/stylesheets/login.less new file mode 100644 index 0000000..2e3bb1e --- /dev/null +++ b/knockoutwhistweb/app/assets/stylesheets/login.less @@ -0,0 +1,35 @@ +.login-box { + position: fixed; /* changed from absolute to fixed so it centers relative to the viewport */ + align-items: center; + justify-content: center; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); /* center exactly */ + display: flex; + width: 100%; + max-width: 420px; /* keeps box from stretching too wide */ + padding: 1rem; + z-index: 2; /* above particles */ +} + +.login-card { + max-width: 400px; + width: 100%; + border: none; + border-radius: 1rem; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + position: relative; + z-index: 3; /* ensure card sits above the particles */ +} + +#particles-js { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; /* behind content */ + pointer-events: none; /* allow clicks through particles */ + background-repeat: no-repeat; + background-size: cover; +} \ No newline at end of file diff --git a/knockoutwhistweb/app/assets/stylesheets/main.less b/knockoutwhistweb/app/assets/stylesheets/main.less index 6c77638..8bc3b69 100644 --- a/knockoutwhistweb/app/assets/stylesheets/main.less +++ b/knockoutwhistweb/app/assets/stylesheets/main.less @@ -1,5 +1,6 @@ @import "light-mode.less"; @import "dark-mode.less"; +@import "login.less"; @background-image: var(--background-image); @color: var(--color); @@ -7,14 +8,16 @@ 0% { transform: translateX(-100vw); } 100% { transform: translateX(0); } } -body { +.game-field-background { background-image: @background-image; background-size: 100vw 100vh; + background-repeat: no-repeat; } -html, body { - height: 100vh; - margin: 0; +.game-field { + position: fixed; + inset: 0; + overflow: auto; } #sessions { display: flex; @@ -31,8 +34,9 @@ html, body { animation: slideIn 0.5s ease-out forwards; animation-fill-mode: backwards; animation-delay: 1s; - } -#sessions a, h1, p { +} + +#sessions a, #sessions h1, #sessions p { color: @color; font-size: 40px; font-family: Arial, serif; @@ -44,6 +48,11 @@ html, body { justify-content: flex-end; height: 100%; } +#ingame a, #ingame h1, #ingame p { + color: @color; + font-size: 40px; + font-family: Arial, serif; +} #playercards { display: flex; flex-direction: row; diff --git a/knockoutwhistweb/app/auth/Auth.scala b/knockoutwhistweb/app/auth/Auth.scala new file mode 100644 index 0000000..b956035 --- /dev/null +++ b/knockoutwhistweb/app/auth/Auth.scala @@ -0,0 +1,37 @@ +package auth + +import controllers.routes +import logic.user.SessionManager +import model.users.User +import play.api.mvc.* + +import javax.inject.Inject +import scala.concurrent.{ExecutionContext, Future} + +class AuthenticatedRequest[A](val user: User, request: Request[A]) extends WrappedRequest[A](request) + +class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyParsers.Default)(implicit ec: ExecutionContext) + extends ActionBuilder[AuthenticatedRequest, AnyContent] { + + override def executionContext: ExecutionContext = ec + + private def getUserFromSession(request: RequestHeader): Option[User] = { + val session = request.cookies.get("sessionId") + if (session.isDefined) + return sessionManager.getUserBySession(session.get.value) + None + } + + override def invokeBlock[A]( + request: Request[A], + block: AuthenticatedRequest[A] => Future[Result] + ): Future[Result] = { + getUserFromSession(request) match { + case Some(user) => + block(new AuthenticatedRequest(user, request)) + case None => + Future.successful(Results.Redirect(routes.UserController.login())) + } + } +} + diff --git a/knockoutwhistweb/app/components/WebApplicationConfiguration.scala b/knockoutwhistweb/app/components/WebApplicationConfiguration.scala index 8226cf8..2f13029 100644 --- a/knockoutwhistweb/app/components/WebApplicationConfiguration.scala +++ b/knockoutwhistweb/app/components/WebApplicationConfiguration.scala @@ -1,13 +1,12 @@ package components -import controllers.WebUI import de.knockoutwhist.components.DefaultConfiguration import de.knockoutwhist.ui.UI import de.knockoutwhist.utils.events.EventListener class WebApplicationConfiguration extends DefaultConfiguration { - override def uis: Set[UI] = super.uis + WebUI - override def listener: Set[EventListener] = super.listener + WebUI + override def uis: Set[UI] = Set() + override def listener: Set[EventListener] = Set() } diff --git a/knockoutwhistweb/app/controllers/HomeController.scala b/knockoutwhistweb/app/controllers/HomeController.scala deleted file mode 100644 index 7de648f..0000000 --- a/knockoutwhistweb/app/controllers/HomeController.scala +++ /dev/null @@ -1,93 +0,0 @@ -package controllers - -import com.google.inject.{Guice, Injector} -import controllers.sessions.AdvancedSession -import de.knockoutwhist.KnockOutWhist -import de.knockoutwhist.components.Configuration -import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} -import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic -import di.KnockOutWebConfigurationModule -import play.api.mvc.* -import play.api.* -import play.twirl.api.Html - -import java.util.UUID -import javax.inject.* - - -/** - * This controller creates an `Action` to handle HTTP requests to the - * application's home page. - */ -@Singleton -class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController { - - private var initial = false - private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule()) - - /** - * Create an Action to render an HTML page. - * - * The configuration in the `routes` file means that this method - * will be called when the application receives a `GET` request with - * a path of `/`. - */ - def index(): Action[AnyContent] = { - if (!initial) { - initial = true - KnockOutWhist.entry(injector.getInstance(classOf[Configuration])) - } - Action { implicit request => - Redirect("/sessions") - } - } - def rules(): Action[AnyContent] = { - Action { implicit request => - Ok(views.html.rules.apply()) - } - } - def sessions(): Action[AnyContent] = { - Action { implicit request => - Ok(views.html.sessions.apply(PodGameManager.listSessions())) - } - } - - def ingame(id: String): Action[AnyContent] = { - val uuid: UUID = UUID.fromString(id) - if (PodGameManager.identify(uuid).isEmpty) { - return Action { implicit request => - NotFound(views.html.tui.apply(List(Html(s"
Session with id $id not found!
")))) - } - } else { - val session = PodGameManager.identify(uuid).get - val player = session.asInstanceOf[AdvancedSession].player - val logic = WebUI.logic.get.asInstanceOf[BaseGameLogic] - if (logic.getCurrentState == Lobby) { - - } else if (logic.getCurrentState == InGame) { - return Action { implicit request => - Ok(views.html.ingame.apply(player, logic)) - } - } else if (logic.getCurrentState == SelectTrump) { - return Action { implicit request => - Ok(views.html.selecttrump.apply(player, logic)) - } - } else if (logic.getCurrentState == TieBreak) { - return Action { implicit request => - Ok(views.html.tie.apply(player, logic)) - } - } - } - Action { implicit request => - InternalServerError("Oops") - } - //if (logic.getCurrentState == Lobby) { - //Action { implicit request => - //Ok(views.html.tui.apply(player, logic)) - //} - //} else { - //Action { implicit request => - //Ok(views.html.tui.apply(player, logic)) - //} - } -} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/IngameController.scala b/knockoutwhistweb/app/controllers/IngameController.scala new file mode 100644 index 0000000..ef588a8 --- /dev/null +++ b/knockoutwhistweb/app/controllers/IngameController.scala @@ -0,0 +1,244 @@ +package controllers + +import auth.{AuthAction, AuthenticatedRequest} +import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} +import exceptions.{CantPlayCardException, GameFullException, NotEnoughPlayersException, NotHostException, NotInThisGameException} +import logic.PodManager +import play.api.* +import play.api.mvc.* + +import javax.inject.* +import scala.util.Try + + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's home page. + */ +@Singleton +class IngameController @Inject()( + val controllerComponents: ControllerComponents, + val authAction: AuthAction, + val podManager: PodManager + ) extends BaseController { + + def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + game match { + case Some(g) => + g.logic.getCurrentState match { + case Lobby => Ok("Lobby: " + gameId) + case InGame => + Ok(views.html.ingame.ingame( + g.getPlayerByUser(request.user), + g.logic + )) + case SelectTrump => + Ok(views.html.ingame.selecttrump( + g.getPlayerByUser(request.user), + g.logic + )) + case TieBreak => + Ok(views.html.ingame.tie( + g.getPlayerByUser(request.user), + g.logic + )) + case _ => + InternalServerError(s"Invalid game state for in-game view. GameId: $gameId" + s" State: ${g.logic.getCurrentState}") + } + case None => + NotFound("Game not found") + } + //NotFound(s"Reached end of game method unexpectedly. GameId: $gameId") + } + def startGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + val result = Try { + game match { + case Some(g) => + g.startGame(request.user) + case None => + NotFound("Game not found") + } + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: NotHostException => + Forbidden(throwable.getMessage) + case _: NotEnoughPlayersException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + } + def joinGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + val result = Try { + game match { + case Some(g) => + g.addUser(request.user) + case None => + NotFound("Game not found") + } + } + if (result.isSuccess) { + Redirect(routes.IngameController.game(gameId)) + } else { + val throwable = result.failed.get + throwable match { + case _: GameFullException => + BadRequest(throwable.getMessage) + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + } + def playCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => { + val game = podManager.getGame(gameId) + game match { + case Some(g) => + val cardIdOpt = request.body.asFormUrlEncoded.flatMap(_.get("cardId").flatMap(_.headOption)) + cardIdOpt match { + case Some(cardId) => + val result = Try { + g.playCard(g.getUserSession(request.user.id), cardId.toInt) + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: CantPlayCardException => + BadRequest(throwable.getMessage) + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + case None => + BadRequest("cardId parameter is missing") + } + case None => + NotFound("Game not found") + } + } + } + def playDogCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => { + val game = podManager.getGame(gameId) + game match { + case Some(g) => { + val cardIdOpt = request.body.asFormUrlEncoded.flatMap(_.get("cardId").flatMap(_.headOption)) + val result = Try { + cardIdOpt match { + case Some(cardId) if cardId == "skip" => + g.playDogCard(g.getUserSession(request.user.id), -1) + case Some(cardId) => + g.playDogCard(g.getUserSession(request.user.id), cardId.toInt) + case None => + throw new IllegalArgumentException("cardId parameter is missing") + } + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: CantPlayCardException => + BadRequest(throwable.getMessage) + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + } + case None => + NotFound("Game not found") + } + } + } + def playTrump(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + game match { + case Some(g) => + val trumpOpt = request.body.asFormUrlEncoded.flatMap(_.get("trump").flatMap(_.headOption)) + trumpOpt match { + case Some(trump) => + val result = Try { + g.selectTrump(g.getUserSession(request.user.id), trump.toInt) + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + case None => + BadRequest("trump parameter is missing") + } + case None => + NotFound("Game not found") + } + } + def playTie(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + game match { + case Some(g) => + val tieOpt = request.body.asFormUrlEncoded.flatMap(_.get("tie").flatMap(_.headOption)) + tieOpt match { + case Some(tie) => + val result = Try { + g.selectTie(g.getUserSession(request.user.id), tie.toInt) + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + case None => + BadRequest("tie parameter is missing") + } + case None => + NotFound("Game not found") + } + } + +} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/MainMenuController.scala b/knockoutwhistweb/app/controllers/MainMenuController.scala new file mode 100644 index 0000000..a7a1637 --- /dev/null +++ b/knockoutwhistweb/app/controllers/MainMenuController.scala @@ -0,0 +1,45 @@ +package controllers + +import auth.{AuthAction, AuthenticatedRequest} +import logic.PodManager +import play.api.* +import play.api.mvc.* + +import javax.inject.* + + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's home page. + */ +@Singleton +class MainMenuController @Inject()( + val controllerComponents: ControllerComponents, + val authAction: AuthAction, + val podManager: PodManager + ) extends BaseController { + + // Pass the request-handling function directly to authAction (no nested Action) + def mainMenu(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + Ok("Main Menu for user: " + request.user.name) + } + + def index(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + Redirect(routes.MainMenuController.mainMenu()) + } + + def createGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val gameLobby = podManager.createGame( + host = request.user, + name = s"${request.user.name}'s Game", + maxPlayers = 4 + ) + Redirect(routes.IngameController.game(gameLobby.id)) + } + + def rules(): Action[AnyContent] = { + Action { implicit request => + Ok(views.html.mainmenu.rules()) + } + } +} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/PodGameManager.scala b/knockoutwhistweb/app/controllers/PodGameManager.scala deleted file mode 100644 index fc9da31..0000000 --- a/knockoutwhistweb/app/controllers/PodGameManager.scala +++ /dev/null @@ -1,37 +0,0 @@ -package controllers - -import controllers.sessions.PlayerSession -import de.knockoutwhist.utils.events.SimpleEvent - -import java.util.UUID -import scala.collection.mutable - -object PodGameManager { - - private val sessions: mutable.Map[UUID, PlayerSession] = mutable.Map() - - def addSession(session: PlayerSession): Unit = { - sessions.put(session.id, session) - } - - def clearSessions(): Unit = { - sessions.clear() - } - - def identify(id: UUID): Option[PlayerSession] = { - sessions.get(id) - } - - def transmit(id: UUID, event: SimpleEvent): Unit = { - identify(id).foreach(_.updatePlayer(event)) - } - - def transmitAll(event: SimpleEvent): Unit = { - sessions.foreach(session => session._2.updatePlayer(event)) - } - - def listSessions(): List[PlayerSession] = { - sessions.values.toList - } - -} diff --git a/knockoutwhistweb/app/controllers/UserController.scala b/knockoutwhistweb/app/controllers/UserController.scala new file mode 100644 index 0000000..361826f --- /dev/null +++ b/knockoutwhistweb/app/controllers/UserController.scala @@ -0,0 +1,70 @@ +package controllers + +import auth.{AuthAction, AuthenticatedRequest} +import logic.user.{SessionManager, UserManager} +import play.api.* +import play.api.mvc.* + +import javax.inject.* + + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's home page. + */ +@Singleton +class UserController @Inject()( + val controllerComponents: ControllerComponents, + val sessionManager: SessionManager, + val userManager: UserManager, + val authAction: AuthAction + ) extends BaseController { + + def login(): Action[AnyContent] = { + Action { implicit request => + val session = request.cookies.get("sessionId") + if (session.isDefined) { + val possibleUser = sessionManager.getUserBySession(session.get.value) + if (possibleUser.isDefined) { + Redirect(routes.MainMenuController.mainMenu()) + } else { + Ok(views.html.login.login()) + } + } else { + Ok(views.html.login.login()) + } + } + } + + def login_Post(): Action[AnyContent] = { + Action { implicit request => + val postData = request.body.asFormUrlEncoded + if (postData.isDefined) { + // Extract username and password from form data + val username = postData.get.get("username").flatMap(_.headOption).getOrElse("") + val password = postData.get.get("password").flatMap(_.headOption).getOrElse("") + val possibleUser = userManager.authenticate(username, password) + if (possibleUser.isDefined) { + Redirect(routes.MainMenuController.mainMenu()).withCookies( + Cookie("sessionId", sessionManager.createSession(possibleUser.get)) + ) + } else { + println("Failed login attempt for user: " + username) + Unauthorized("Invalid username or password") + } + } else { + BadRequest("Invalid form submission") + } + } + } + + // Pass the request-handling function directly to authAction (no nested Action) + def logout(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val sessionCookie = request.cookies.get("sessionId") + if (sessionCookie.isDefined) { + sessionManager.invalidateSession(sessionCookie.get.value) + } + NoContent.discardingCookies(DiscardingCookie("sessionId")) + } + +} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/WebUI.scala b/knockoutwhistweb/app/controllers/WebUI.scala deleted file mode 100644 index 726a1c4..0000000 --- a/knockoutwhistweb/app/controllers/WebUI.scala +++ /dev/null @@ -1,49 +0,0 @@ -package controllers - -import controllers.sessions.AdvancedSession -import de.knockoutwhist.cards.{Card, CardValue, Hand, Suit} -import de.knockoutwhist.control.GameLogic -import de.knockoutwhist.control.GameState.{InGame, Lobby} -import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic -import de.knockoutwhist.events.* -import de.knockoutwhist.events.global.GameStateChangeEvent -import de.knockoutwhist.player.AbstractPlayer -import de.knockoutwhist.rounds.Match -import de.knockoutwhist.ui.UI -import de.knockoutwhist.utils.CustomThread -import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} - -object WebUI extends CustomThread with EventListener with UI { - - setName("WebUI") - - var init = false - var logic: Option[GameLogic] = None - - var latestOutput: String = "" - - override def instance: CustomThread = WebUI - - override def listen(event: SimpleEvent): Unit = { - event match { - case event: GameStateChangeEvent => - if (event.oldState == Lobby && event.newState == InGame) { - val match1: Option[Match] = logic.get.asInstanceOf[BaseGameLogic].getCurrentMatch - val players: List[AbstractPlayer] = match1.get.totalplayers - players.map(player => PodGameManager.addSession(AdvancedSession(player.id, player))) - } - case _ => - } - } - - override def initial(gameLogic: GameLogic): Boolean = { - if (init) { - return false - } - init = true - this.logic = Some(gameLogic) - start() - true - } - -} diff --git a/knockoutwhistweb/app/exceptions/CantPlayCardException.java b/knockoutwhistweb/app/exceptions/CantPlayCardException.java new file mode 100644 index 0000000..a313c07 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/CantPlayCardException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class CantPlayCardException extends GameException { + public CantPlayCardException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/GameException.java b/knockoutwhistweb/app/exceptions/GameException.java new file mode 100644 index 0000000..5e001f2 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/GameException.java @@ -0,0 +1,7 @@ +package exceptions; + +public abstract class GameException extends RuntimeException { + public GameException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/GameFullException.java b/knockoutwhistweb/app/exceptions/GameFullException.java new file mode 100644 index 0000000..f0db380 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/GameFullException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class GameFullException extends GameException { + public GameFullException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/NotEnoughPlayersException.java b/knockoutwhistweb/app/exceptions/NotEnoughPlayersException.java new file mode 100644 index 0000000..35c8d44 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/NotEnoughPlayersException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class NotEnoughPlayersException extends GameException { + public NotEnoughPlayersException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/NotHostException.java b/knockoutwhistweb/app/exceptions/NotHostException.java new file mode 100644 index 0000000..423bbfd --- /dev/null +++ b/knockoutwhistweb/app/exceptions/NotHostException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class NotHostException extends GameException { + public NotHostException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/NotInThisGameException.java b/knockoutwhistweb/app/exceptions/NotInThisGameException.java new file mode 100644 index 0000000..fad03ed --- /dev/null +++ b/knockoutwhistweb/app/exceptions/NotInThisGameException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class NotInThisGameException extends GameException { + public NotInThisGameException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/NotInteractableException.java b/knockoutwhistweb/app/exceptions/NotInteractableException.java new file mode 100644 index 0000000..4c71ac7 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/NotInteractableException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class NotInteractableException extends GameException { + public NotInteractableException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/logic/PodManager.scala b/knockoutwhistweb/app/logic/PodManager.scala new file mode 100644 index 0000000..ad3e9c8 --- /dev/null +++ b/knockoutwhistweb/app/logic/PodManager.scala @@ -0,0 +1,49 @@ +package logic + +import com.google.inject.{Guice, Injector} +import de.knockoutwhist.components.Configuration +import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic +import di.KnockOutWebConfigurationModule +import logic.game.GameLobby +import model.users.User +import util.GameUtil + +import javax.inject.Singleton +import scala.collection.mutable + +@Singleton +class PodManager { + + val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds + val podIp: String = System.getenv("POD_IP") + val podName: String = System.getenv("POD_NAME") + + private val sessions: mutable.Map[String, GameLobby] = mutable.Map() + private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule()) + + def createGame( + host: User, + name: String, + maxPlayers: Int + ): GameLobby = { + val gameLobby = GameLobby( + logic = BaseGameLogic(injector.getInstance(classOf[Configuration])), + id = GameUtil.generateCode(), + internalId = java.util.UUID.randomUUID(), + name = name, + maxPlayers = maxPlayers, + host = host + ) + sessions += (gameLobby.id -> gameLobby) + gameLobby + } + + def getGame(gameId: String): Option[GameLobby] = { + sessions.get(gameId) + } + + private[logic] def removeGame(gameId: String): Unit = { + sessions.remove(gameId) + } + +} diff --git a/knockoutwhistweb/app/logic/game/GameLobby.scala b/knockoutwhistweb/app/logic/game/GameLobby.scala new file mode 100644 index 0000000..7dadf4a --- /dev/null +++ b/knockoutwhistweb/app/logic/game/GameLobby.scala @@ -0,0 +1,246 @@ +package logic.game + +import de.knockoutwhist.cards.{Hand, Suit} +import de.knockoutwhist.control.GameLogic +import de.knockoutwhist.control.GameState.{Lobby, MainMenu} +import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil} +import de.knockoutwhist.events.global.{GameStateChangeEvent, SessionClosed} +import de.knockoutwhist.events.player.PlayerEvent +import de.knockoutwhist.player.Playertype.HUMAN +import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory} +import de.knockoutwhist.rounds.{Match, Round, Trick} +import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} +import exceptions.* +import model.sessions.{InteractionType, UserSession} +import model.users.User + +import java.util.UUID +import scala.collection.mutable +import scala.collection.mutable.ListBuffer + +class GameLobby private( + val logic: GameLogic, + val id: String, + val internalId: UUID, + val name: String, + val maxPlayers: Int + ) extends EventListener { + logic.addListener(this) + logic.createSession() + + private val users: mutable.Map[UUID, UserSession] = mutable.Map() + + def addUser(user: User): UserSession = { + if (users.size >= maxPlayers) throw new GameFullException("The game is full!") + if (users.contains(user.id)) throw new IllegalArgumentException("User is already in the game!") + if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!") + val userSession = new UserSession( + user = user, + host = false + ) + users += (user.id -> userSession) + userSession + } + + override def listen(event: SimpleEvent): Unit = { + event match { + case event: PlayerEvent => + users.get(event.playerId).foreach(session => session.updatePlayer(event)) + case event: GameStateChangeEvent => + if (event.oldState == MainMenu && event.newState == Lobby) { + return + } + users.values.foreach(session => session.updatePlayer(event)) + case event: SessionClosed => + users.values.foreach(session => session.updatePlayer(event)) + case event: SimpleEvent => + users.values.foreach(session => session.updatePlayer(event)) + } + } + + /** + * Start the game if the user is the host. + * @param user the user who wants to start the game. + */ + def startGame(user: User): Unit = { + val sessionOpt = users.get(user.id) + if (sessionOpt.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + if (!sessionOpt.get.host) { + throw new NotHostException("Only the host can start the game!") + } + val playerNamesList = ListBuffer[AbstractPlayer]() + users.values.foreach { player => + playerNamesList += PlayerFactory.createPlayer(player.name, player.id, HUMAN) + } + if (playerNamesList.size < 2) { + throw new NotEnoughPlayersException("Not enough players to start the game!") + } + logic.createMatch(playerNamesList.toList) + logic.controlMatch() + } + + /** + * Remove the user from the game lobby. + * @param user the user who wants to leave the game. + */ + def leaveGame(user: User): Unit = { + val sessionOpt = users.get(user.id) + if (sessionOpt.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + users.remove(user.id) + } + + /** + * Play a card from the player's hand. + * @param userSession the user session of the player. + * @param cardIndex the index of the card in the player's hand. + */ + def playCard(userSession: UserSession, cardIndex: Int): Unit = { + val player = getPlayerInteractable(userSession, InteractionType.Card) + if (player.isInDogLife) { + throw new CantPlayCardException("You are in dog life!") + } + val hand = getHand(player) + val card = hand.cards(cardIndex) + if (!PlayerUtil.canPlayCard(card, getRound, getTrick, player)) { + throw new CantPlayCardException("You can't play this card!") + } + logic.playerInputLogic.receivedCard(card) + } + + /** + * Play a card from the player's hand while in dog life or skip the round. + * @param userSession the user session of the player. + * @param cardIndex the index of the card in the player's hand or -1 if the player wants to skip the round. + */ + def playDogCard(userSession: UserSession, cardIndex: Int): Unit = { + val player = getPlayerInteractable(userSession, InteractionType.DogCard) + if (!player.isInDogLife) { + throw new CantPlayCardException("You are not in dog life!") + } + if (cardIndex == -1) { + if (!MatchUtil.dogNeedsToPlay(getMatch, getRound)) { + throw new CantPlayCardException("You can't skip this round!") + } + logic.playerInputLogic.receivedDog(None) + } + val hand = getHand(player) + val card = hand.cards(cardIndex) + logic.playerInputLogic.receivedDog(Some(card)) + } + + /** + * Select the trump suit for the round. + * @param userSession the user session of the player. + * @param trumpIndex the index of the trump suit. + */ + def selectTrump(userSession: UserSession, trumpIndex: Int): Unit = { + val player = getPlayerInteractable(userSession, InteractionType.TrumpSuit) + val trumpSuits = Suit.values.toList + val selectedTrump = trumpSuits(trumpIndex) + logic.playerInputLogic.receivedTrumpSuit(selectedTrump) + } + + /** + * + * @param userSession + * @param tieNumber + */ + def selectTie(userSession: UserSession, tieNumber: Int): Unit = { + val player = getPlayerInteractable(userSession, InteractionType.TieChoice) + logic.playerTieLogic.receivedTieBreakerCard(tieNumber) + } + + + //------------------- + + def getUserSession(userId: UUID): UserSession = { + val sessionOpt = users.get(userId) + if (sessionOpt.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + sessionOpt.get + } + + def getPlayerByUser(user: User): AbstractPlayer = { + getPlayerBySession(getUserSession(user.id)) + } + + private def getPlayerBySession(userSession: UserSession): AbstractPlayer = { + val playerOption = getMatch.totalplayers.find(_.id == userSession.id) + if (playerOption.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + playerOption.get + } + + private def getPlayerInteractable(userSession: UserSession, iType: InteractionType): AbstractPlayer = { + if (!Thread.holdsLock(userSession.lock)) { + throw new IllegalStateException("The user session is not locked!") + } + if (userSession.canInteract.isEmpty || userSession.canInteract.get != iType) { + throw new NotInteractableException("You can't play a card!") + } + getPlayerBySession(userSession) + } + + private def getHand(player: AbstractPlayer): Hand = { + val handOption = player.currentHand() + if (handOption.isEmpty) { + throw new IllegalStateException("You have no cards!") + } + handOption.get + } + + private def getMatch: Match = { + val matchOpt = logic.getCurrentMatch + if (matchOpt.isEmpty) { + throw new IllegalStateException("No match is currently running!") + } + matchOpt.get + } + + private def getRound: Round = { + val roundOpt = logic.getCurrentRound + if (roundOpt.isEmpty) { + throw new IllegalStateException("No round is currently running!") + } + roundOpt.get + } + + private def getTrick: Trick = { + val trickOpt = logic.getCurrentTrick + if (trickOpt.isEmpty) { + throw new IllegalStateException("No trick is currently running!") + } + trickOpt.get + } + +} + +object GameLobby { + def apply( + logic: GameLogic, + id: String, + internalId: UUID, + name: String, + maxPlayers: Int, + host: User + ): GameLobby = { + val lobby = new GameLobby( + logic = logic, + id = id, + internalId = internalId, + name = name, + maxPlayers = maxPlayers + ) + lobby.users += (host.id -> new UserSession( + user = host, + host = true + )) + lobby + } +} diff --git a/knockoutwhistweb/app/logic/user/SessionManager.scala b/knockoutwhistweb/app/logic/user/SessionManager.scala new file mode 100644 index 0000000..aad7472 --- /dev/null +++ b/knockoutwhistweb/app/logic/user/SessionManager.scala @@ -0,0 +1,14 @@ +package logic.user + +import com.google.inject.ImplementedBy +import logic.user.impl.BaseSessionManager +import model.users.User + +@ImplementedBy(classOf[BaseSessionManager]) +trait SessionManager { + + def createSession(user: User): String + def getUserBySession(sessionId: String): Option[User] + def invalidateSession(sessionId: String): Unit + +} diff --git a/knockoutwhistweb/app/logic/user/UserManager.scala b/knockoutwhistweb/app/logic/user/UserManager.scala new file mode 100644 index 0000000..ecf3a8d --- /dev/null +++ b/knockoutwhistweb/app/logic/user/UserManager.scala @@ -0,0 +1,16 @@ +package logic.user + +import com.google.inject.ImplementedBy +import logic.user.impl.StubUserManager +import model.users.User + +@ImplementedBy(classOf[StubUserManager]) +trait UserManager { + + def addUser(name: String, password: String): Boolean + def authenticate(name: String, password: String): Option[User] + def userExists(name: String): Option[User] + def userExistsById(id: Long): Option[User] + def removeUser(name: String): Boolean + +} diff --git a/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala b/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala new file mode 100644 index 0000000..1efe06c --- /dev/null +++ b/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala @@ -0,0 +1,63 @@ +package logic.user.impl + +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.{JWT, JWTVerifier} +import com.github.benmanes.caffeine.cache.{Cache, Caffeine} +import com.typesafe.config.Config +import logic.user.SessionManager +import model.users.User +import scalafx.util.Duration +import services.JwtKeyProvider + +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.TimeUnit +import javax.inject.{Inject, Singleton} + +@Singleton +class BaseSessionManager @Inject()(val keyProvider: JwtKeyProvider, val userManager: StubUserManager, val config: Config) extends SessionManager { + + private val algorithm = Algorithm.RSA512(keyProvider.publicKey, keyProvider.privateKey) + private val verifier: JWTVerifier = JWT.require(algorithm) + .withIssuer(config.getString("auth.issuer")) + .withAudience(config.getString("auth.audience")) + .build() + + //TODO reduce cache to a minimum amount, as JWT should be self-contained + private val cache: Cache[String, User] = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(5, TimeUnit.MINUTES).build() + + override def createSession(user: User): String = { + //Write session identifier to cache and DB + val sessionId = JWT.create() + .withIssuer(config.getString("auth.issuer")) + .withAudience(config.getString("auth.audience")) + .withSubject(user.id.toString) + .withClaim("id", user.internalId) + .withExpiresAt(Instant.now.plus(7, ChronoUnit.DAYS)) + .sign(algorithm) + //TODO write to Redis and DB + cache.put(sessionId, user) + + sessionId + } + + override def getUserBySession(sessionId: String): Option[User] = { + //TODO verify JWT token instead of looking up in cache + val cachedUser = cache.getIfPresent(sessionId) + if (cachedUser != null) { + Some(cachedUser) + } else { + val decoded = verifier.verify(sessionId) + val user = userManager.userExistsById(decoded.getClaim("id").asLong()) + user.foreach(u => cache.put(sessionId, u)) + user + } + } + + override def invalidateSession(sessionId: String): Unit = { + //TODO remove from Redis and DB + cache.invalidate(sessionId) + } +} diff --git a/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala new file mode 100644 index 0000000..44f71ee --- /dev/null +++ b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala @@ -0,0 +1,51 @@ +package logic.user.impl + +import com.typesafe.config.Config +import logic.user.UserManager +import model.users.User +import util.UserHash + +import javax.inject.{Inject, Singleton} + +@Singleton +class StubUserManager @Inject()(val config: Config) extends UserManager { + + private val user: Map[String, User] = Map( + "Janis" -> User( + internalId = 1L, + id = java.util.UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), + name = "Janis", + passwordHash = UserHash.hashPW("password123") + ), + "Leon" -> User( + internalId = 2L, + id = java.util.UUID.fromString("223e4567-e89b-12d3-a456-426614174000"), + name = "Leon", + passwordHash = UserHash.hashPW("password123") + ) + ) + + override def addUser(name: String, password: String): Boolean = { + throw new NotImplementedError("StubUserManager.addUser is not implemented") + } + + override def authenticate(name: String, password: String): Option[User] = { + user.get(name) match { + case Some(u) if UserHash.verifyUser(password, u) => Some(u) + case _ => None + } + } + + override def userExists(name: String): Option[User] = { + user.get(name) + } + + override def userExistsById(id: Long): Option[User] = { + user.values.find(_.internalId == id) + } + + override def removeUser(name: String): Boolean = { + throw new NotImplementedError("StubUserManager.removeUser is not implemented") + } + +} diff --git a/knockoutwhistweb/app/model/sessions/InteractionType.scala b/knockoutwhistweb/app/model/sessions/InteractionType.scala new file mode 100644 index 0000000..e265edb --- /dev/null +++ b/knockoutwhistweb/app/model/sessions/InteractionType.scala @@ -0,0 +1,10 @@ +package model.sessions + +enum InteractionType { + + case TrumpSuit + case Card + case DogCard + case TieChoice + +} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/sessions/PlayerSession.scala b/knockoutwhistweb/app/model/sessions/PlayerSession.scala similarity index 86% rename from knockoutwhistweb/app/controllers/sessions/PlayerSession.scala rename to knockoutwhistweb/app/model/sessions/PlayerSession.scala index e76ddc2..95c39f5 100644 --- a/knockoutwhistweb/app/controllers/sessions/PlayerSession.scala +++ b/knockoutwhistweb/app/model/sessions/PlayerSession.scala @@ -1,4 +1,4 @@ -package controllers.sessions +package model.sessions import de.knockoutwhist.utils.events.SimpleEvent diff --git a/knockoutwhistweb/app/controllers/sessions/AdvancedSession.scala b/knockoutwhistweb/app/model/sessions/SimpleSession.scala similarity index 66% rename from knockoutwhistweb/app/controllers/sessions/AdvancedSession.scala rename to knockoutwhistweb/app/model/sessions/SimpleSession.scala index 770e3fb..a4c7007 100644 --- a/knockoutwhistweb/app/controllers/sessions/AdvancedSession.scala +++ b/knockoutwhistweb/app/model/sessions/SimpleSession.scala @@ -1,11 +1,11 @@ -package controllers.sessions +package model.sessions import de.knockoutwhist.player.AbstractPlayer import de.knockoutwhist.utils.events.SimpleEvent import java.util.UUID -case class AdvancedSession(id: UUID, player: AbstractPlayer) extends PlayerSession { +case class SimpleSession(id: UUID, player: AbstractPlayer) extends PlayerSession { def name: String = player.name diff --git a/knockoutwhistweb/app/model/sessions/UserSession.scala b/knockoutwhistweb/app/model/sessions/UserSession.scala new file mode 100644 index 0000000..df45464 --- /dev/null +++ b/knockoutwhistweb/app/model/sessions/UserSession.scala @@ -0,0 +1,31 @@ +package model.sessions + +import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent} +import de.knockoutwhist.utils.events.SimpleEvent +import model.users.User + +import java.util.UUID +import java.util.concurrent.locks.{Lock, ReentrantLock} + +class UserSession(user: User, val host: Boolean) extends PlayerSession { + var canInteract: Option[InteractionType] = None + val lock: Lock = ReentrantLock() + + override def updatePlayer(event: SimpleEvent): Unit = { + event match { + case event: RequestTrumpSuitEvent => + canInteract = Some(InteractionType.TrumpSuit) + case event: RequestTieChoiceEvent => + canInteract = Some(InteractionType.TieChoice) + case event: RequestCardEvent => + if (event.player.isInDogLife) canInteract = Some(InteractionType.DogCard) + else canInteract = Some(InteractionType.Card) + case _ => + } + } + + override def id: UUID = user.id + + override def name: String = user.name + +} diff --git a/knockoutwhistweb/app/model/users/User.scala b/knockoutwhistweb/app/model/users/User.scala new file mode 100644 index 0000000..e56c048 --- /dev/null +++ b/knockoutwhistweb/app/model/users/User.scala @@ -0,0 +1,20 @@ +package model.users + +import java.util.UUID + +case class User( + internalId: Long, + id: UUID, + name: String, + passwordHash: String + ) { + + def withName(newName: String): User = { + this.copy(name = newName) + } + + private def withPasswordHash(newPasswordHash: String): User = { + this.copy(passwordHash = newPasswordHash) + } + +} diff --git a/knockoutwhistweb/app/services/JwtKeyProvider.scala b/knockoutwhistweb/app/services/JwtKeyProvider.scala new file mode 100644 index 0000000..f1fb46f --- /dev/null +++ b/knockoutwhistweb/app/services/JwtKeyProvider.scala @@ -0,0 +1,56 @@ +package services + +import play.api.Configuration + +import java.nio.file.{Files, Paths} +import java.security.interfaces.{RSAPrivateKey, RSAPublicKey} +import java.security.spec.{PKCS8EncodedKeySpec, RSAPublicKeySpec, X509EncodedKeySpec} +import java.security.{KeyFactory, KeyPair, PrivateKey, PublicKey} +import java.util.Base64 +import javax.inject.* + +@Singleton +class JwtKeyProvider @Inject()(config: Configuration) { + + private def cleanPem(pem: String): String = + pem.replaceAll("-----BEGIN (.*)-----", "") + .replaceAll("-----END (.*)-----", "") + .replaceAll("\\s", "") + + private def loadPublicKeyFromPem(pem: String): RSAPublicKey = { + val decoded = Base64.getDecoder.decode(cleanPem(pem)) + val spec = new X509EncodedKeySpec(decoded) + KeyFactory.getInstance("RSA").generatePublic(spec).asInstanceOf[RSAPublicKey] + } + + private def loadPrivateKeyFromPem(pem: String): RSAPrivateKey = { + val decoded = Base64.getDecoder.decode(cleanPem(pem)) + val spec = new PKCS8EncodedKeySpec(decoded) + KeyFactory.getInstance("RSA").generatePrivate(spec).asInstanceOf[RSAPrivateKey] + } + + val publicKey: RSAPublicKey = { + val pemOpt = config.getOptional[String]("auth.publicKeyPem") + val fileOpt = config.getOptional[String]("auth.publicKeyFile") + + pemOpt.orElse(fileOpt.map { path => + new String(Files.readAllBytes(Paths.get(path))) + }) match { + case Some(pem) => loadPublicKeyFromPem(pem) + case None => throw new RuntimeException("No RSA public key configured.") + } + } + + val privateKey: RSAPrivateKey = { + val pemOpt = config.getOptional[String]("auth.privateKeyPem") + val fileOpt = config.getOptional[String]("auth.privateKeyFile") + + pemOpt.orElse(fileOpt.map { path => + new String(Files.readAllBytes(Paths.get(path))) + }) match { + case Some(pem) => loadPrivateKeyFromPem(pem) + case None => throw new RuntimeException("No RSA private key configured.") + } + } + +} diff --git a/knockoutwhistweb/app/util/GameUtil.scala b/knockoutwhistweb/app/util/GameUtil.scala new file mode 100644 index 0000000..b2caf50 --- /dev/null +++ b/knockoutwhistweb/app/util/GameUtil.scala @@ -0,0 +1,29 @@ +package util + +import scala.util.Random + +object GameUtil { + + private val CharPool: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + private val CodeLength: Int = 6 + private val MaxRepetition: Int = 2 + private val random = new Random() + + def generateCode(): String = { + val freq = Array.fill(CharPool.length)(0) + val code = new StringBuilder(CodeLength) + + for (_ <- 0 until CodeLength) { + var index = random.nextInt(CharPool.length) + // Pick a new character if it's already used twice + while (freq(index) >= MaxRepetition) { + index = random.nextInt(CharPool.length) + } + freq(index) += 1 + code.append(CharPool.charAt(index)) + } + + code.toString() + } + +} diff --git a/knockoutwhistweb/app/util/UserHash.scala b/knockoutwhistweb/app/util/UserHash.scala new file mode 100644 index 0000000..1c48cab --- /dev/null +++ b/knockoutwhistweb/app/util/UserHash.scala @@ -0,0 +1,23 @@ +package util + +import de.mkammerer.argon2.Argon2Factory +import de.mkammerer.argon2.Argon2Factory.Argon2Types +import model.users.User + +object UserHash { + private val ITERATIONS: Int = 3 + private val MEMORY: Int = 32_768 + private val PARALLELISM: Int = 1 + private val SALT_LENGTH: Int = 32 + private val HASH_LENGTH: Int = 64 + private val ARGON_2 = Argon2Factory.create(Argon2Types.ARGON2id, SALT_LENGTH, HASH_LENGTH) + + def hashPW(password: String): String = { + ARGON_2.hash(ITERATIONS, MEMORY, PARALLELISM, password.toCharArray) + } + + def verifyUser(password: String, user: User): Boolean = { + ARGON_2.verify(user.passwordHash, password.toCharArray) + } + +} diff --git a/knockoutwhistweb/app/util/WebUIUtils.scala b/knockoutwhistweb/app/util/WebUIUtils.scala index 84e0558..b905462 100644 --- a/knockoutwhistweb/app/util/WebUIUtils.scala +++ b/knockoutwhistweb/app/util/WebUIUtils.scala @@ -29,6 +29,6 @@ object WebUIUtils { case Three => "3" case Two => "2" } - views.html.output.card.apply(f"images/cards/$cv$s.png")(card.toString) + views.html.render.card.apply(f"images/cards/$cv$s.png")(card.toString) } } diff --git a/knockoutwhistweb/app/views/index.scala.html b/knockoutwhistweb/app/views/index.scala.html deleted file mode 100644 index d4fdd74..0000000 --- a/knockoutwhistweb/app/views/index.scala.html +++ /dev/null @@ -1,3 +0,0 @@ -@main("Welcome to Play") { -Next Player:
@@ -17,7 +17,7 @@ @if(logic.getCurrentTrick.get.firstCard.isDefined) { @util.WebUIUtils.cardtoImage(logic.getCurrentTrick.get.firstCard.get) } else { - @views.html.output.card.apply("images/cards/1B.png")("Blank Card") + @views.html.render.card.apply("../../../public/images/cards/1B.png")("Blank Card") }You (@player.toString) have won the last round! Select a trumpsuit for the next round!
diff --git a/knockoutwhistweb/app/views/tie.scala.html b/knockoutwhistweb/app/views/ingame/tie.scala.html similarity index 91% rename from knockoutwhistweb/app/views/tie.scala.html rename to knockoutwhistweb/app/views/ingame/tie.scala.html index 75b74aa..505c68d 100644 --- a/knockoutwhistweb/app/views/tie.scala.html +++ b/knockoutwhistweb/app/views/ingame/tie.scala.html @@ -1,7 +1,7 @@ -@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) +@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.GameLogic) @main("Tie") { -The last Round was tied between @for(players <- logic.playerTieLogic.getTiedPlayers) { diff --git a/knockoutwhistweb/app/views/login/login.scala.html b/knockoutwhistweb/app/views/login/login.scala.html new file mode 100644 index 0000000..219707e --- /dev/null +++ b/knockoutwhistweb/app/views/login/login.scala.html @@ -0,0 +1,41 @@ +@() + +@main("Login") { +
+ Don’t have an account? + Sign up +
+