feat: Create authorization
This commit is contained in:
@@ -52,6 +52,14 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb"))
|
|||||||
libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.3",
|
libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.3",
|
||||||
libraryDependencies += "tools.jackson.module" %% "jackson-module-scala" % "3.0.2",
|
libraryDependencies += "tools.jackson.module" %% "jackson-module-scala" % "3.0.2",
|
||||||
libraryDependencies += "de.janis" % "knockoutwhist-data" % "1.0-SNAPSHOT",
|
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
|
JsEngineKeys.engineType := JsEngineKeys.EngineType.Node
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Submodule knockoutwhistfrontend updated: 6b8488e7a4...3dda2fefc2
146
knockoutwhistweb/app/controllers/OpenIDController.scala
Normal file
146
knockoutwhistweb/app/controllers/OpenIDController.scala
Normal file
@@ -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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] =>
|
def logoutPost(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
||||||
val sessionCookie = request.cookies.get("accessToken")
|
val sessionCookie = request.cookies.get("accessToken")
|
||||||
if (sessionCookie.isDefined) {
|
if (sessionCookie.isDefined) {
|
||||||
|
|||||||
22
knockoutwhistweb/app/di/EntityManagerProvider.scala
Normal file
22
knockoutwhistweb/app/di/EntityManagerProvider.scala
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
knockoutwhistweb/app/di/ProductionModule.scala
Normal file
25
knockoutwhistweb/app/di/ProductionModule.scala
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,19 @@ package logic.user
|
|||||||
import com.google.inject.ImplementedBy
|
import com.google.inject.ImplementedBy
|
||||||
import logic.user.impl.StubUserManager
|
import logic.user.impl.StubUserManager
|
||||||
import model.users.User
|
import model.users.User
|
||||||
|
import services.OpenIDUserInfo
|
||||||
|
|
||||||
@ImplementedBy(classOf[StubUserManager])
|
@ImplementedBy(classOf[StubUserManager])
|
||||||
trait UserManager {
|
trait UserManager {
|
||||||
|
|
||||||
def addUser(name: String, password: String): Boolean
|
def addUser(name: String, password: String): Boolean
|
||||||
|
|
||||||
|
def addOpenIDUser(name: String, userInfo: OpenIDUserInfo): Boolean
|
||||||
|
|
||||||
def authenticate(name: String, password: String): Option[User]
|
def authenticate(name: String, password: String): Option[User]
|
||||||
|
|
||||||
|
def authenticateOpenID(provider: String, providerId: String): Option[User]
|
||||||
|
|
||||||
def userExists(name: String): Option[User]
|
def userExists(name: String): Option[User]
|
||||||
|
|
||||||
def userExistsById(id: Long): Option[User]
|
def userExistsById(id: Long): Option[User]
|
||||||
|
|||||||
161
knockoutwhistweb/app/logic/user/impl/HibernateUserManager.scala
Normal file
161
knockoutwhistweb/app/logic/user/impl/HibernateUserManager.scala
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,26 +3,22 @@ package logic.user.impl
|
|||||||
import com.typesafe.config.Config
|
import com.typesafe.config.Config
|
||||||
import logic.user.UserManager
|
import logic.user.UserManager
|
||||||
import model.users.User
|
import model.users.User
|
||||||
|
import services.OpenIDUserInfo
|
||||||
import util.UserHash
|
import util.UserHash
|
||||||
|
|
||||||
import javax.inject.{Inject, Singleton}
|
import javax.inject.{Inject, Singleton}
|
||||||
|
import scala.collection.mutable
|
||||||
|
|
||||||
@Singleton
|
@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(
|
"Janis" -> User(
|
||||||
internalId = 1L,
|
internalId = 1L,
|
||||||
id = java.util.UUID.fromString("123e4567-e89b-12d3-a456-426614174000"),
|
id = java.util.UUID.fromString("123e4567-e89b-12d3-a456-426614174000"),
|
||||||
name = "Janis",
|
name = "Janis",
|
||||||
passwordHash = UserHash.hashPW("password123")
|
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(
|
"Jakob" -> User(
|
||||||
internalId = 2L,
|
internalId = 2L,
|
||||||
id = java.util.UUID.fromString("323e4567-e89b-12d3-a456-426614174000"),
|
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")
|
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] = {
|
override def authenticate(name: String, password: String): Option[User] = {
|
||||||
user.get(name) match {
|
user.get(name) match {
|
||||||
case Some(u) if UserHash.verifyUser(password, u) => Some(u)
|
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] = {
|
override def userExists(name: String): Option[User] = {
|
||||||
user.get(name)
|
user.get(name)
|
||||||
}
|
}
|
||||||
@@ -51,7 +66,6 @@ class StubUserManager @Inject()(val config: Config) extends UserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override def removeUser(name: String): Boolean = {
|
override def removeUser(name: String): Boolean = {
|
||||||
throw new NotImplementedError("StubUserManager.removeUser is not implemented")
|
user.remove(name).isDefined
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
80
knockoutwhistweb/app/model/users/UserEntity.scala
Normal file
80
knockoutwhistweb/app/model/users/UserEntity.scala
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
164
knockoutwhistweb/app/services/OpenIDConnectService.scala
Normal file
164
knockoutwhistweb/app/services/OpenIDConnectService.scala
Normal file
@@ -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("-", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,3 +22,26 @@ play.filters.cors {
|
|||||||
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||||
allowedHttpHeaders = ["Accept", "Content-Type", "Origin", "X-Requested-With"]
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
38
knockoutwhistweb/conf/persistence.xml
Normal file
38
knockoutwhistweb/conf/persistence.xml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence
|
||||||
|
https://jakarta.ee/xml/ns/persistence/persistence_3_0.xsd"
|
||||||
|
version="3.0">
|
||||||
|
|
||||||
|
<persistence-unit name="defaultPersistenceUnit">
|
||||||
|
<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
|
||||||
|
|
||||||
|
<class>model.users.UserEntity</class>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<!-- Database connection settings -->
|
||||||
|
<property name="jakarta.persistence.jdbc.driver" value="org.postgresql.Driver"/>
|
||||||
|
<property name="jakarta.persistence.jdbc.url" value="${DATABASE_URL}"/>
|
||||||
|
<property name="jakarta.persistence.jdbc.user" value="${DB_USER}"/>
|
||||||
|
<property name="jakarta.persistence.jdbc.password" value="${DB_PASSWORD}"/>
|
||||||
|
|
||||||
|
<!-- Hibernate specific settings -->
|
||||||
|
<property name="hibernate.dialect" value="org.hibernate.dialect.PostgreSQLDialect"/>
|
||||||
|
<property name="hibernate.hbm2ddl.auto" value="update"/>
|
||||||
|
<property name="hibernate.show_sql" value="false"/>
|
||||||
|
<property name="hibernate.format_sql" value="true"/>
|
||||||
|
<property name="hibernate.use_sql_comments" value="true"/>
|
||||||
|
|
||||||
|
<!-- Connection pool settings -->
|
||||||
|
<property name="hibernate.connection.provider_class" value="org.hibernate.hikaricp.internal.HikariCPConnectionProvider"/>
|
||||||
|
<property name="hibernate.hikari.maximumPoolSize" value="20"/>
|
||||||
|
<property name="hibernate.hikari.minimumIdle" value="5"/>
|
||||||
|
<property name="hibernate.hikari.connectionTimeout" value="30000"/>
|
||||||
|
<property name="hibernate.hikari.idleTimeout" value="600000"/>
|
||||||
|
<property name="hibernate.hikari.maxLifetime" value="1800000"/>
|
||||||
|
<property name="hibernate.hikari.poolName" value="KnockOutWhistPool"/>
|
||||||
|
</properties>
|
||||||
|
</persistence-unit>
|
||||||
|
|
||||||
|
</persistence>
|
||||||
@@ -7,8 +7,56 @@ play.http.context="/api"
|
|||||||
play.modules.enabled += "modules.GatewayModule"
|
play.modules.enabled += "modules.GatewayModule"
|
||||||
|
|
||||||
play.filters.cors {
|
play.filters.cors {
|
||||||
allowedOrigins = ["https://knockout.janis-eccarius.de"]
|
allowedOrigins = ["http://localhost:5173"]
|
||||||
allowedCredentials = true
|
allowedCredentials = true
|
||||||
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||||
allowedHttpHeaders = ["Accept", "Content-Type", "Origin", "X-Requested-With"]
|
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,9 +19,16 @@ POST /joinGame/:gameId controllers.MainMenuController.joinGame(gam
|
|||||||
|
|
||||||
# User authentication routes
|
# User authentication routes
|
||||||
POST /login controllers.UserController.login_Post()
|
POST /login controllers.UserController.login_Post()
|
||||||
|
POST /register controllers.UserController.register()
|
||||||
POST /logout controllers.UserController.logoutPost()
|
POST /logout controllers.UserController.logoutPost()
|
||||||
GET /userInfo controllers.UserController.getUserInfo()
|
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
|
# In-game routes
|
||||||
GET /game/:id controllers.IngameController.game(id: String)
|
GET /game/:id controllers.IngameController.game(id: String)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user