diff --git a/app/controllers/authentication/SessionsController.scala b/app/controllers/authentication/SessionsController.scala new file mode 100644 index 0000000..50c7218 --- /dev/null +++ b/app/controllers/authentication/SessionsController.scala @@ -0,0 +1,43 @@ +package controllers.authentication + +import dao.{AuthTokensDAO, UsersDAO} +import jakarta.inject.{Inject, Singleton} +import models.AuthToken +import play.api.libs.json.* +import play.api.libs.functional.syntax.* +import play.api.mvc.{AbstractController, ControllerComponents} + +import scala.concurrent.{ExecutionContext, Future} + +case class LoginData(username: String, password: String) +implicit val loginDataReads: Reads[LoginData] = ( + (JsPath \ "username").read[String] and + (JsPath \ "password").read[String] +)(LoginData.apply) +implicit val tokenWrites: Writes[AuthToken] = (token: AuthToken) => Json.obj( + "token" -> token.token, + "createdAt" -> token.createdAt, + "expiresAt" -> token.expiresAt +) + + +@Singleton +class SessionsController @Inject()(controllerComponents: ControllerComponents, usersDAO: UsersDAO, authTokensDAO: AuthTokensDAO, implicit val ec: ExecutionContext) extends AbstractController(controllerComponents) { + def login() = Action.async(parse.json) { implicit request => + val loginDataResult = request.body.validate[LoginData] + loginDataResult.fold( + errors => { + Future.successful(BadRequest(Json.obj("message" -> JsError.toJson(errors)))) + }, + loginData => { + usersDAO.authenticate(loginData.username, loginData.password).flatMap { userOption => + userOption.fold( + Future.successful(UnprocessableEntity(Json.obj("message" -> "Invalid authentication"))) + ){ user => + authTokensDAO.createToken(user).map(token => Ok(Json.toJson(token))) + } + } + } + ) + } +} diff --git a/app/dao/AuthTokensDAO.scala b/app/dao/AuthTokensDAO.scala new file mode 100644 index 0000000..39f73c7 --- /dev/null +++ b/app/dao/AuthTokensDAO.scala @@ -0,0 +1,35 @@ +package dao + +import jakarta.inject.Inject +import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider} +import slick.jdbc.PostgresProfile +import slick.jdbc.PostgresProfile.api._ + +import java.nio.ByteBuffer +import java.security.SecureRandom +import java.util.Base64 +import java.time.{Instant, Duration} + +import scala.concurrent.{ExecutionContext, Future} +import models.AuthToken +import schemas.AuthTokens + +class AuthTokensDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext) extends HasDatabaseConfigProvider[PostgresProfile] { + private lazy val random = SecureRandom() + private lazy val base64 = Base64.getEncoder + private final lazy val tokenValidDuration = Duration.ofDays(30) + + def createToken(user: models.User): Future[AuthToken] = { + val tokenRaw = ByteBuffer.allocate(10) + random.nextBytes(tokenRaw.array()) + val token = AuthToken( + id = None, + userId = user.id.get, + token = base64.encodeToString(tokenRaw.array()), + createdAt = Instant.now, + expiresAt = Instant.now.plus(tokenValidDuration) + ) + + db.run((AuthTokens returning AuthTokens.map(_.id)) += token).map { tokenId => token.copy(id = Some(tokenId)) } + } +} diff --git a/app/dao/UsersDAO.scala b/app/dao/UsersDAO.scala new file mode 100644 index 0000000..f3e1f88 --- /dev/null +++ b/app/dao/UsersDAO.scala @@ -0,0 +1,31 @@ +package dao + +import de.mkammerer.argon2.Argon2Factory +import execution.CryptoExecutionContext +import jakarta.inject.Inject +import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider} +import slick.jdbc.PostgresProfile +import slick.jdbc.PostgresProfile.api.* + +import scala.concurrent.{ExecutionContext, Future} +import models.User +import schemas.Users + +class UsersDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider, private val cryptoEC: CryptoExecutionContext)(implicit val ec: ExecutionContext) + extends HasDatabaseConfigProvider[PostgresProfile] { + private val argon2 = Argon2Factory.create() + + def authenticate(username: String, password: String): Future[Option[User]] = { + val query = Users.filter(_.username === username).take(1).result.headOption + + val userFuture = db.run(query) + + userFuture.map { userOption => + for { + user <- userOption + digest <- user.passwordDigest + if argon2.verify(digest, password.getBytes("UTF-8")) + } yield user + }(using cryptoEC) + } +} diff --git a/app/execution/CryptoExecutionContext.scala b/app/execution/CryptoExecutionContext.scala new file mode 100644 index 0000000..46067bc --- /dev/null +++ b/app/execution/CryptoExecutionContext.scala @@ -0,0 +1,8 @@ +package execution + +import jakarta.inject.{Inject, Singleton} +import org.apache.pekko.actor.ActorSystem +import play.api.libs.concurrent.CustomExecutionContext + +@Singleton +class CryptoExecutionContext @Inject()(system: ActorSystem) extends CustomExecutionContext(system, "pekko.actor.crypto-dispatcher") diff --git a/build.sbt b/build.sbt index fe457b4..7ce3f6b 100644 --- a/build.sbt +++ b/build.sbt @@ -18,6 +18,9 @@ libraryDependencies += "org.playframework" %% "play-slick" % "6.2.0" // Source: https://mvnrepository.com/artifact/org.playframework/play-slick-evolutions libraryDependencies += "org.playframework" %% "play-slick-evolutions" % "6.2.0" +// Source: https://mvnrepository.com/artifact/de.mkammerer/argon2-jvm +libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12" + // Adds additional packages into Twirl //TwirlKeys.templateImports += "me.artemis.controllers._" diff --git a/conf/application.conf b/conf/application.conf index 0435719..56a51aa 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -12,4 +12,17 @@ slick.dbs.default = { password = ${POSTGRES_PASSWORD} } } +} + +pekko { + actor { + crypto-dispatcher { + type = Dispatcher + executor = "thread-pool-executor" + thread-pool-executor { + fixed-pool-size = 2 + } + throughput = 1 + } + } } \ No newline at end of file diff --git a/conf/routes b/conf/routes index 60e8169..4db9750 100644 --- a/conf/routes +++ b/conf/routes @@ -6,5 +6,7 @@ # An example controller showing a sample home page GET / controllers.HomeController.index() +POST /login controllers.authentication.SessionsController.login() + # Map static resources from the /public folder to the /assets URL path GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)