From af88f5c559a7bcb0f44b1826757fc50b7536218c Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 18 Jan 2026 22:16:51 +0100 Subject: [PATCH] feat: Create authorization --- build.sbt | 8 + knockoutwhistfrontend | 2 +- .../app/controllers/OpenIDController.scala | 146 ++++++++++++++++ .../app/controllers/UserController.scala | 41 +++++ .../app/di/EntityManagerProvider.scala | 22 +++ .../app/di/ProductionModule.scala | 25 +++ .../app/logic/user/UserManager.scala | 5 + .../user/impl/HibernateUserManager.scala | 161 +++++++++++++++++ .../app/logic/user/impl/StubUserManager.scala | 34 ++-- .../app/model/users/UserEntity.scala | 80 +++++++++ .../app/services/OpenIDConnectService.scala | 164 ++++++++++++++++++ knockoutwhistweb/conf/application.conf | 23 +++ knockoutwhistweb/conf/persistence.xml | 38 ++++ knockoutwhistweb/conf/prod.conf | 50 +++++- knockoutwhistweb/conf/routes | 7 + 15 files changed, 794 insertions(+), 12 deletions(-) create mode 100644 knockoutwhistweb/app/controllers/OpenIDController.scala create mode 100644 knockoutwhistweb/app/di/EntityManagerProvider.scala create mode 100644 knockoutwhistweb/app/di/ProductionModule.scala create mode 100644 knockoutwhistweb/app/logic/user/impl/HibernateUserManager.scala create mode 100644 knockoutwhistweb/app/model/users/UserEntity.scala create mode 100644 knockoutwhistweb/app/services/OpenIDConnectService.scala create mode 100644 knockoutwhistweb/conf/persistence.xml diff --git a/build.sbt b/build.sbt index 6051547..7f1c07e 100644 --- a/build.sbt +++ b/build.sbt @@ -52,6 +52,14 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb")) libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.3", libraryDependencies += "tools.jackson.module" %% "jackson-module-scala" % "3.0.2", libraryDependencies += "de.janis" % "knockoutwhist-data" % "1.0-SNAPSHOT", + libraryDependencies += "org.hibernate.orm" % "hibernate-core" % "6.4.4.Final", + libraryDependencies += "jakarta.persistence" % "jakarta.persistence-api" % "3.1.0", + libraryDependencies += "org.postgresql" % "postgresql" % "42.7.4", + libraryDependencies += "org.playframework" %% "play-jdbc" % "3.0.6", + libraryDependencies += "org.playframework" %% "play-java-jpa" % "3.0.6", + libraryDependencies += "com.nimbusds" % "oauth2-oidc-sdk" % "11.31.1", + libraryDependencies += "org.playframework" %% "play-ws" % "3.0.6", + libraryDependencies += ws, JsEngineKeys.engineType := JsEngineKeys.EngineType.Node ) diff --git a/knockoutwhistfrontend b/knockoutwhistfrontend index 6b8488e..3dda2fe 160000 --- a/knockoutwhistfrontend +++ b/knockoutwhistfrontend @@ -1 +1 @@ -Subproject commit 6b8488e7a4b47c397e8e366412d32f40781e3b8b +Subproject commit 3dda2fefc20465ff143e0a3ea4c0ac1b4b806332 diff --git a/knockoutwhistweb/app/controllers/OpenIDController.scala b/knockoutwhistweb/app/controllers/OpenIDController.scala new file mode 100644 index 0000000..14f8f2f --- /dev/null +++ b/knockoutwhistweb/app/controllers/OpenIDController.scala @@ -0,0 +1,146 @@ +package controllers + +import auth.AuthAction +import com.typesafe.config.Config +import logic.user.{SessionManager, UserManager} +import model.users.User +import play.api.libs.json.Json +import play.api.mvc.* +import play.api.mvc.Cookie.SameSite.Lax +import services.{OpenIDConnectService, OpenIDUserInfo} + +import javax.inject.* +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class OpenIDController @Inject()( + val controllerComponents: ControllerComponents, + val openIDService: OpenIDConnectService, + val sessionManager: SessionManager, + val userManager: UserManager, + val config: Config + )(implicit ec: ExecutionContext) extends BaseController { + + def loginWithProvider(provider: String) = Action.async { implicit request => + val state = openIDService.generateState() + val nonce = openIDService.generateNonce() + + // Store state and nonce in session + openIDService.getAuthorizationUrl(provider, state, nonce) match { + case Some(authUrl) => + Future.successful(Redirect(authUrl) + .withSession( + "oauth_state" -> state, + "oauth_nonce" -> nonce, + "oauth_provider" -> provider + )) + case None => + Future.successful(BadRequest(Json.obj("error" -> "Unsupported provider"))) + } + } + + def callback(provider: String) = Action.async { implicit request => + val sessionState = request.session.get("oauth_state") + val sessionNonce = request.session.get("oauth_nonce") + val sessionProvider = request.session.get("oauth_provider") + + val returnedState = request.getQueryString("state") + val code = request.getQueryString("code") + val error = request.getQueryString("error") + + error match { + case Some(err) => + Future.successful(Redirect("/login").flashing("error" -> s"Authentication failed: $err")) + case None => + (for { + _ <- Option(sessionState.contains(returnedState.getOrElse(""))) + _ <- Option(sessionProvider.contains(provider)) + authCode <- code + } yield { + openIDService.exchangeCodeForTokens(provider, authCode, sessionState.get).flatMap { + case Some(tokenResponse) => + openIDService.getUserInfo(provider, tokenResponse.accessToken).map { + case Some(userInfo) => + // Store user info in session for username selection + Redirect("http://localhost:5173/select-username") + .withSession( + "oauth_user_info" -> Json.toJson(userInfo).toString(), + "oauth_provider" -> provider, + "oauth_access_token" -> tokenResponse.accessToken + ) + case None => + Redirect("/login").flashing("error" -> "Failed to retrieve user information") + } + case None => + Future.successful(Redirect("/login").flashing("error" -> "Failed to exchange authorization code")) + } + }).getOrElse { + Future.successful(Redirect("/login").flashing("error" -> "Invalid state parameter")) + } + } + } + + def selectUsername() = Action.async { implicit request => + request.session.get("oauth_user_info") match { + case Some(userInfoJson) => + val userInfo = Json.parse(userInfoJson).as[OpenIDUserInfo] + Future.successful(Ok(Json.obj( + "id" -> userInfo.id, + "email" -> userInfo.email, + "name" -> userInfo.name, + "picture" -> userInfo.picture, + "provider" -> userInfo.provider + ))) + case None => + Future.successful(Redirect("/login").flashing("error" -> "No authentication information found")) + } + } + + def submitUsername() = Action.async { implicit request => + val username = request.body.asJson.flatMap(json => (json \ "username").asOpt[String]) + .orElse(request.body.asFormUrlEncoded.flatMap(_.get("username").flatMap(_.headOption))) + val userInfoJson = request.session.get("oauth_user_info") + val provider = request.session.get("oauth_provider").getOrElse("unknown") + + (username, userInfoJson) match { + case (Some(uname), Some(userInfoJson)) => + val userInfo = Json.parse(userInfoJson).as[OpenIDUserInfo] + + // Check if username already exists + val trimmedUsername = uname.trim + userManager.userExists(trimmedUsername) match { + case Some(_) => + Future.successful(Conflict(Json.obj("error" -> "Username already taken"))) + case None => + // Create new user with OpenID info (no password needed) + val success = userManager.addOpenIDUser(trimmedUsername, userInfo) + if (success) { + // Get the created user and create session + userManager.userExists(trimmedUsername) match { + case Some(user) => + val sessionToken = sessionManager.createSession(user) + Future.successful(Ok(Json.obj( + "message" -> "User created successfully", + "user" -> Json.obj( + "id" -> user.id, + "username" -> user.name + ) + )).withCookies(Cookie( + name = "accessToken", + value = sessionToken, + httpOnly = true, + secure = false, + sameSite = Some(Lax) + )).removingFromSession("oauth_user_info", "oauth_provider", "oauth_access_token")) + case None => + Future.successful(InternalServerError(Json.obj("error" -> "Failed to create user session"))) + } + } else { + Future.successful(InternalServerError(Json.obj("error" -> "Failed to create user"))) + } + } + case _ => + Future.successful(BadRequest(Json.obj("error" -> "Username is required"))) + } + } +} diff --git a/knockoutwhistweb/app/controllers/UserController.scala b/knockoutwhistweb/app/controllers/UserController.scala index 392c7ff..466bf02 100644 --- a/knockoutwhistweb/app/controllers/UserController.scala +++ b/knockoutwhistweb/app/controllers/UserController.scala @@ -66,6 +66,47 @@ class UserController @Inject()( )) } + def register(): Action[AnyContent] = { + Action { implicit request => + val jsonBody = request.body.asJson + val username: Option[String] = jsonBody.flatMap { jsValue => + (jsValue \ "username").asOpt[String] + } + val password: Option[String] = jsonBody.flatMap { jsValue => + (jsValue \ "password").asOpt[String] + } + + if (username.isDefined && password.isDefined) { + // Validate input + if (username.get.trim.isEmpty || password.get.length < 6) { + BadRequest(Json.obj( + "error" -> "Invalid input", + "message" -> "Username must not be empty and password must be at least 6 characters" + )) + } else { + // Try to register user + val registrationSuccess = userManager.addUser(username.get.trim, password.get) + if (registrationSuccess) { + Created(Json.obj( + "message" -> "User registered successfully", + "username" -> username.get.trim + )) + } else { + Conflict(Json.obj( + "error" -> "User already exists", + "message" -> "Username is already taken" + )) + } + } + } else { + BadRequest(Json.obj( + "error" -> "Invalid request", + "message" -> "Username and password are required" + )) + } + } + } + def logoutPost(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => val sessionCookie = request.cookies.get("accessToken") if (sessionCookie.isDefined) { diff --git a/knockoutwhistweb/app/di/EntityManagerProvider.scala b/knockoutwhistweb/app/di/EntityManagerProvider.scala new file mode 100644 index 0000000..9b86bb1 --- /dev/null +++ b/knockoutwhistweb/app/di/EntityManagerProvider.scala @@ -0,0 +1,22 @@ +package di + +import com.google.inject.Provider +import com.google.inject.Inject +import jakarta.inject.Singleton +import jakarta.persistence.{EntityManager, EntityManagerFactory, Persistence} + +@Singleton +class EntityManagerProvider @Inject()() extends Provider[EntityManager] { + + private val emf: EntityManagerFactory = Persistence.createEntityManagerFactory("defaultPersistenceUnit") + + override def get(): EntityManager = { + emf.createEntityManager() + } + + def close(): Unit = { + if (emf.isOpen) { + emf.close() + } + } +} diff --git a/knockoutwhistweb/app/di/ProductionModule.scala b/knockoutwhistweb/app/di/ProductionModule.scala new file mode 100644 index 0000000..5939616 --- /dev/null +++ b/knockoutwhistweb/app/di/ProductionModule.scala @@ -0,0 +1,25 @@ +package di + +import com.google.inject.AbstractModule +import com.google.inject.name.Names +import logic.user.impl.HibernateUserManager +import play.api.db.DBApi +import play.api.{Configuration, Environment} + +class ProductionModule( + environment: Environment, + configuration: Configuration +) extends AbstractModule { + + override def configure(): Unit = { + // Bind HibernateUserManager for production + bind(classOf[logic.user.UserManager]) + .to(classOf[logic.user.impl.HibernateUserManager]) + .asEagerSingleton() + + // Bind EntityManager for JPA + bind(classOf[jakarta.persistence.EntityManager]) + .toProvider(classOf[EntityManagerProvider]) + .asEagerSingleton() + } +} diff --git a/knockoutwhistweb/app/logic/user/UserManager.scala b/knockoutwhistweb/app/logic/user/UserManager.scala index 98e1912..cd76014 100644 --- a/knockoutwhistweb/app/logic/user/UserManager.scala +++ b/knockoutwhistweb/app/logic/user/UserManager.scala @@ -3,14 +3,19 @@ package logic.user import com.google.inject.ImplementedBy import logic.user.impl.StubUserManager import model.users.User +import services.OpenIDUserInfo @ImplementedBy(classOf[StubUserManager]) trait UserManager { def addUser(name: String, password: String): Boolean + def addOpenIDUser(name: String, userInfo: OpenIDUserInfo): Boolean + def authenticate(name: String, password: String): Option[User] + def authenticateOpenID(provider: String, providerId: String): Option[User] + def userExists(name: String): Option[User] def userExistsById(id: Long): Option[User] diff --git a/knockoutwhistweb/app/logic/user/impl/HibernateUserManager.scala b/knockoutwhistweb/app/logic/user/impl/HibernateUserManager.scala new file mode 100644 index 0000000..e61cfa0 --- /dev/null +++ b/knockoutwhistweb/app/logic/user/impl/HibernateUserManager.scala @@ -0,0 +1,161 @@ +package logic.user.impl + +import com.typesafe.config.Config +import jakarta.inject.Inject +import jakarta.persistence.EntityManager +import logic.user.UserManager +import model.users.{User, UserEntity} +import services.OpenIDUserInfo +import util.UserHash + +import javax.inject.Singleton +import scala.jdk.CollectionConverters.* + +@Singleton +class HibernateUserManager @Inject()(em: EntityManager, config: Config) extends UserManager { + + override def addUser(name: String, password: String): Boolean = { + try { + // Check if user already exists + val existing = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity]) + .setParameter("username", name) + .getResultList + + if (!existing.isEmpty) { + return false + } + + // Create new user + val userEntity = UserEntity.fromUser(User( + internalId = 0L, // Will be set by database + id = java.util.UUID.randomUUID(), + name = name, + passwordHash = UserHash.hashPW(password) + )) + + em.persist(userEntity) + em.flush() + true + } catch { + case _: Exception => false + } + } + + override def addOpenIDUser(name: String, userInfo: OpenIDUserInfo): Boolean = { + try { + // Check if user already exists + val existing = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity]) + .setParameter("username", name) + .getResultList + + if (!existing.isEmpty) { + return false + } + + // Check if OpenID user already exists + val existingOpenID = em.createQuery( + "SELECT u FROM UserEntity u WHERE u.openidProvider = :provider AND u.openidProviderId = :providerId", + classOf[UserEntity] + ) + .setParameter("provider", userInfo.provider) + .setParameter("providerId", userInfo.id) + .getResultList + + if (!existingOpenID.isEmpty) { + return false + } + + // Create new OpenID user + val userEntity = UserEntity.fromOpenIDUser(name, userInfo) + + em.persist(userEntity) + em.flush() + true + } catch { + case _: Exception => false + } + } + + override def authenticate(name: String, password: String): Option[User] = { + try { + val users = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity]) + .setParameter("username", name) + .getResultList + + if (users.isEmpty) { + return None + } + + val userEntity = users.get(0) + if (UserHash.verifyUser(password, userEntity.toUser)) { + Some(userEntity.toUser) + } else { + None + } + } catch { + case _: Exception => None + } + } + + override def authenticateOpenID(provider: String, providerId: String): Option[User] = { + try { + val users = em.createQuery( + "SELECT u FROM UserEntity u WHERE u.openidProvider = :provider AND u.openidProviderId = :providerId", + classOf[UserEntity] + ) + .setParameter("provider", provider) + .setParameter("providerId", providerId) + .getResultList + + if (users.isEmpty) { + None + } else { + Some(users.get(0).toUser) + } + } catch { + case _: Exception => None + } + } + + override def userExists(name: String): Option[User] = { + try { + val users = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity]) + .setParameter("username", name) + .getResultList + + if (users.isEmpty) { + None + } else { + Some(users.get(0).toUser) + } + } catch { + case _: Exception => None + } + } + + override def userExistsById(id: Long): Option[User] = { + try { + Option(em.find(classOf[UserEntity], id)).map(_.toUser) + } catch { + case _: Exception => None + } + } + + override def removeUser(name: String): Boolean = { + try { + val users = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity]) + .setParameter("username", name) + .getResultList + + if (users.isEmpty) { + false + } else { + em.remove(users.get(0)) + em.flush() + true + } + } catch { + case _: Exception => false + } + } +} diff --git a/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala index a50bd58..c68d87a 100644 --- a/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala +++ b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala @@ -3,26 +3,22 @@ package logic.user.impl import com.typesafe.config.Config import logic.user.UserManager import model.users.User +import services.OpenIDUserInfo import util.UserHash import javax.inject.{Inject, Singleton} +import scala.collection.mutable @Singleton -class StubUserManager @Inject()(val config: Config) extends UserManager { +class StubUserManager @Inject()(config: Config) extends UserManager { - private val user: Map[String, User] = Map( + private val user: mutable.Map[String, User] = mutable.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") - ), "Jakob" -> User( internalId = 2L, id = java.util.UUID.fromString("323e4567-e89b-12d3-a456-426614174000"), @@ -35,6 +31,18 @@ class StubUserManager @Inject()(val config: Config) extends UserManager { throw new NotImplementedError("StubUserManager.addUser is not implemented") } + override def addOpenIDUser(name: String, userInfo: OpenIDUserInfo): Boolean = { + // For stub implementation, just add a user without password + val newUser = User( + internalId = user.size.toLong + 1, + id = java.util.UUID.randomUUID(), + name = name, + passwordHash = "" // No password for OpenID users + ) + user(name) = newUser + true + } + override def authenticate(name: String, password: String): Option[User] = { user.get(name) match { case Some(u) if UserHash.verifyUser(password, u) => Some(u) @@ -42,6 +50,13 @@ class StubUserManager @Inject()(val config: Config) extends UserManager { } } + override def authenticateOpenID(provider: String, providerId: String): Option[User] = { + user.values.find { u => + // In a real implementation, this would check stored OpenID provider info + u.name.startsWith(s"${provider}_") && u.name.contains(providerId) + } + } + override def userExists(name: String): Option[User] = { user.get(name) } @@ -51,7 +66,6 @@ class StubUserManager @Inject()(val config: Config) extends UserManager { } override def removeUser(name: String): Boolean = { - throw new NotImplementedError("StubUserManager.removeUser is not implemented") + user.remove(name).isDefined } - } diff --git a/knockoutwhistweb/app/model/users/UserEntity.scala b/knockoutwhistweb/app/model/users/UserEntity.scala new file mode 100644 index 0000000..b570106 --- /dev/null +++ b/knockoutwhistweb/app/model/users/UserEntity.scala @@ -0,0 +1,80 @@ +package model.users + +import jakarta.persistence.* + +import java.time.LocalDateTime +import java.util.UUID +import scala.compiletime.uninitialized + +@Entity +@Table(name = "users") +class UserEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = uninitialized + + @Column(name = "uuid", nullable = false, unique = true) + var uuid: UUID = uninitialized + + @Column(name = "username", nullable = false, unique = true) + var username: String = uninitialized + + @Column(name = "password_hash", nullable = false) + var passwordHash: String = uninitialized + + @Column(name = "openid_provider") + var openidProvider: String = uninitialized + + @Column(name = "openid_provider_id") + var openidProviderId: String = uninitialized + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = uninitialized + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = uninitialized + + @PrePersist + def onCreate(): Unit = { + val now = LocalDateTime.now() + createdAt = now + updatedAt = now + if (uuid == null) { + uuid = UUID.randomUUID() + } + } + + @PreUpdate + def onUpdate(): Unit = { + updatedAt = LocalDateTime.now() + } + + def toUser: User = { + User( + internalId = id, + id = uuid, + name = username, + passwordHash = passwordHash + ) + } +} + +object UserEntity { + def fromUser(user: User): UserEntity = { + val entity = new UserEntity() + entity.uuid = user.id + entity.username = user.name + entity.passwordHash = user.passwordHash + entity + } + + def fromOpenIDUser(username: String, userInfo: services.OpenIDUserInfo): UserEntity = { + val entity = new UserEntity() + entity.username = username + entity.passwordHash = "" // No password for OpenID users + entity.openidProvider = userInfo.provider + entity.openidProviderId = userInfo.id + entity + } +} diff --git a/knockoutwhistweb/app/services/OpenIDConnectService.scala b/knockoutwhistweb/app/services/OpenIDConnectService.scala new file mode 100644 index 0000000..cdce115 --- /dev/null +++ b/knockoutwhistweb/app/services/OpenIDConnectService.scala @@ -0,0 +1,164 @@ +package services + +import com.typesafe.config.Config +import play.api.libs.ws.WSClient +import play.api.Configuration +import play.api.libs.json.* + +import java.net.URI +import javax.inject.* +import scala.concurrent.{ExecutionContext, Future} +import com.nimbusds.oauth2.sdk.* +import com.nimbusds.oauth2.sdk.id.* +import com.nimbusds.openid.connect.sdk.* + +import play.api.libs.ws.DefaultBodyWritables.* + +case class OpenIDUserInfo( + id: String, + email: Option[String], + name: Option[String], + picture: Option[String], + provider: String +) + +object OpenIDUserInfo { + implicit val writes: Writes[OpenIDUserInfo] = Json.writes[OpenIDUserInfo] + implicit val reads: Reads[OpenIDUserInfo] = Json.reads[OpenIDUserInfo] +} + +case class OpenIDProvider( + name: String, + clientId: String, + clientSecret: String, + redirectUri: String, + authorizationEndpoint: String, + tokenEndpoint: String, + userInfoEndpoint: String, + scopes: Set[String] = Set("openid", "profile", "email") +) + +case class TokenResponse( + accessToken: String, + tokenType: String, + expiresIn: Option[Int], + refreshToken: Option[String], + idToken: Option[String] +) + +@Singleton +class OpenIDConnectService@Inject(ws: WSClient, config: Configuration)(implicit ec: ExecutionContext) { + + private val providers = Map( + "discord" -> OpenIDProvider( + name = "discord", + clientId = config.get[String]("openid.discord.clientId"), + clientSecret = config.get[String]("openid.discord.clientSecret"), + redirectUri = config.get[String]("openid.discord.redirectUri"), + authorizationEndpoint = "https://discord.com/oauth2/authorize", + tokenEndpoint = "https://discord.com/api/oauth2/token", + userInfoEndpoint = "https://discord.com/api/users/@me", + scopes = Set("identify", "email") + ), + "keycloak" -> OpenIDProvider( + name = "keycloak", + clientId = config.get[String]("openid.keycloak.clientId"), + clientSecret = config.get[String]("openid.keycloak.clientSecret"), + redirectUri = config.get[String]("openid.keycloak.redirectUri"), + authorizationEndpoint = config.get[String]("openid.keycloak.authUrl") + "/protocol/openid-connect/auth", + tokenEndpoint = config.get[String]("openid.keycloak.authUrl") + "/protocol/openid-connect/token", + userInfoEndpoint = config.get[String]("openid.keycloak.authUrl") + "/protocol/openid-connect/userinfo", + scopes = Set("openid", "profile", "email") + ) + ) + + def getAuthorizationUrl(providerName: String, state: String, nonce: String): Option[String] = { + providers.get(providerName).map { provider => + val authRequest = new AuthenticationRequest.Builder( + new ResponseType(ResponseType.Value.CODE), + new com.nimbusds.oauth2.sdk.Scope(provider.scopes.mkString(" ")), + new com.nimbusds.oauth2.sdk.id.ClientID(provider.clientId), + URI.create(provider.redirectUri) + ) + .state(new com.nimbusds.oauth2.sdk.id.State(state)) + .nonce(new Nonce(nonce)) + .endpointURI(URI.create(provider.authorizationEndpoint)) + .build() + + authRequest.toURI.toString + } + } + + def exchangeCodeForTokens(providerName: String, code: String, state: String): Future[Option[TokenResponse]] = { + providers.get(providerName) match { + case Some(provider) => + ws.url(provider.tokenEndpoint) + .withHttpHeaders( + "Accept" -> "application/json", + "Content-Type" -> "application/x-www-form-urlencoded" + ) + .post( + Map( + "client_id" -> Seq(provider.clientId), + "client_secret" -> Seq(provider.clientSecret), + "code" -> Seq(code), + "grant_type" -> Seq("authorization_code"), + "redirect_uri" -> Seq(provider.redirectUri) + ) + ) + .map { response => + if (response.status == 200) { + val json = response.json + Some(TokenResponse( + accessToken = (json \ "access_token").as[String], + tokenType = (json \ "token_type").as[String], + expiresIn = (json \ "expires_in").asOpt[Int], + refreshToken = (json \ "refresh_token").asOpt[String], + idToken = (json \ "id_token").asOpt[String] + )) + } else { + None + } + } + .recover { case _ => None } + case None => Future.successful(None) + } + } + + def getUserInfo(providerName: String, accessToken: String): Future[Option[OpenIDUserInfo]] = { + providers.get(providerName) match { + case Some(provider) => + ws.url(provider.userInfoEndpoint) + .withHttpHeaders("Authorization" -> s"Bearer $accessToken") + .get() + .map { response => + if (response.status == 200) { + val json = response.json + Some(OpenIDUserInfo( + id = (json \ "id").as[String], + email = (json \ "email").asOpt[String], + name = (json \ "name").asOpt[String].orElse((json \ "login").asOpt[String]), + picture = (json \ "picture").asOpt[String].orElse((json \ "avatar_url").asOpt[String]), + provider = providerName + )) + } else { + None + } + } + .recover { case _ => None } + case None => Future.successful(None) + } + } + + def validateState(sessionState: String, returnedState: String): Boolean = { + sessionState == returnedState + } + + def generateState(): String = { + java.util.UUID.randomUUID().toString.replace("-", "") + } + + def generateNonce(): String = { + java.util.UUID.randomUUID().toString.replace("-", "") + } +} \ No newline at end of file diff --git a/knockoutwhistweb/conf/application.conf b/knockoutwhistweb/conf/application.conf index 3d41095..10e73a7 100644 --- a/knockoutwhistweb/conf/application.conf +++ b/knockoutwhistweb/conf/application.conf @@ -22,3 +22,26 @@ play.filters.cors { allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"] allowedHttpHeaders = ["Accept", "Content-Type", "Origin", "X-Requested-With"] } + +# Local Development OpenID Connect Configuration +openid { + discord { + clientId = ${?DISCORD_CLIENT_ID} + clientId = "your-discord-client-id" + clientSecret = ${?DISCORD_CLIENT_SECRET} + clientSecret = "your-discord-client-secret" + redirectUri = ${?DISCORD_REDIRECT_URI} + redirectUri = "http://localhost:9000/auth/discord/callback" + } + + keycloak { + clientId = ${?KEYCLOAK_CLIENT_ID} + clientId = "your-keycloak-client-id" + clientSecret = ${?KEYCLOAK_CLIENT_SECRET} + clientSecret = "your-keycloak-client-secret" + redirectUri = ${?KEYCLOAK_REDIRECT_URI} + redirectUri = "http://localhost:9000/auth/keycloak/callback" + authUrl = ${?KEYCLOAK_AUTH_URL} + authUrl = "http://localhost:8080/realms/master" + } +} diff --git a/knockoutwhistweb/conf/persistence.xml b/knockoutwhistweb/conf/persistence.xml new file mode 100644 index 0000000..7002250 --- /dev/null +++ b/knockoutwhistweb/conf/persistence.xml @@ -0,0 +1,38 @@ + + + + + org.hibernate.jpa.HibernatePersistenceProvider + + model.users.UserEntity + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/knockoutwhistweb/conf/prod.conf b/knockoutwhistweb/conf/prod.conf index bec057c..2931569 100644 --- a/knockoutwhistweb/conf/prod.conf +++ b/knockoutwhistweb/conf/prod.conf @@ -7,8 +7,56 @@ play.http.context="/api" play.modules.enabled += "modules.GatewayModule" play.filters.cors { - allowedOrigins = ["https://knockout.janis-eccarius.de"] + allowedOrigins = ["http://localhost:5173"] allowedCredentials = true allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"] allowedHttpHeaders = ["Accept", "Content-Type", "Origin", "X-Requested-With"] +} + +# Database configuration - PostgreSQL with environment variables +db.default.driver=org.postgresql.Driver +db.default.url=${?DATABASE_URL} +db.default.url="jdbc:postgresql://localhost:5432/knockoutwhist" +db.default.username=${?DB_USER} +db.default.username="postgres" +db.default.password=${?DB_PASSWORD} +db.default.password="" + +# JPA/Hibernate configuration +jpa.default=defaultPersistenceUnit + +# Hibernate specific settings +hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +hibernate.hbm2ddl.auto=update +hibernate.show_sql=false +hibernate.format_sql=true +hibernate.use_sql_comments=true + +# Connection pool settings +db.default.hikaricp.maximumPoolSize=20 +db.default.hikaricp.minimumIdle=5 +db.default.hikaricp.connectionTimeout=30000 +db.default.hikaricp.idleTimeout=600000 +db.default.hikaricp.maxLifetime=1800000 + +# PostgreSQL specific settings +db.default.hikaricp.connectionTestQuery="SELECT 1" +db.default.hikaricp.poolName="KnockOutWhistPool" + +# OpenID Connect Configuration +openid { + discord { + clientId = ${?DISCORD_CLIENT_ID} + clientSecret = ${?DISCORD_CLIENT_SECRET} + redirectUri = ${?DISCORD_REDIRECT_URI} + redirectUri = "http://localhost:9000/auth/discord/callback" + } + + keycloak { + clientId = "your-keycloak-client-id" + clientSecret = "your-keycloak-client-secret" + redirectUri = "https://knockout.janis-eccarius.de/api/auth/keycloak/callback" + authUrl = ${?KEYCLOAK_AUTH_URL} + authUrl = "http://localhost:8080/realms/master" + } } \ No newline at end of file diff --git a/knockoutwhistweb/conf/routes b/knockoutwhistweb/conf/routes index 2fb460e..3da5462 100644 --- a/knockoutwhistweb/conf/routes +++ b/knockoutwhistweb/conf/routes @@ -19,9 +19,16 @@ POST /joinGame/:gameId controllers.MainMenuController.joinGame(gam # User authentication routes POST /login controllers.UserController.login_Post() +POST /register controllers.UserController.register() POST /logout controllers.UserController.logoutPost() GET /userInfo controllers.UserController.getUserInfo() +# OpenID Connect routes +GET /auth/:provider controllers.OpenIDController.loginWithProvider(provider: String) +GET /auth/:provider/callback controllers.OpenIDController.callback(provider: String) +GET /select-username controllers.OpenIDController.selectUsername() +POST /submit-username controllers.OpenIDController.submitUsername() + # In-game routes GET /game/:id controllers.IngameController.game(id: String)