From f2ec6889cb9198af3a6115170ef96805da161d23 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Wed, 2 Sep 2026 00:40:09 +0300 Subject: [PATCH] Dev actions controller/router + user registration --- .../devel/DevActionsController.scala | 32 ++++++++++ app/controllers/devel/DevRouter.scala | 24 ++++++++ app/dao/UsersDAO.scala | 58 +++++++++++++++++-- app/models/models.scala | 9 +++ app/schemas/schemas.scala | 4 +- conf/routes | 5 +- 6 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 app/controllers/devel/DevActionsController.scala create mode 100644 app/controllers/devel/DevRouter.scala diff --git a/app/controllers/devel/DevActionsController.scala b/app/controllers/devel/DevActionsController.scala new file mode 100644 index 0000000..e6a6cc9 --- /dev/null +++ b/app/controllers/devel/DevActionsController.scala @@ -0,0 +1,32 @@ +package controllers.devel + +import dao.UsersDAO +import play.api.libs.functional.syntax.toFunctionalBuilderOps +import play.api.libs.json.{JsError, JsPath, Json, Reads} +import play.api.mvc.* + +import javax.inject.Inject +import scala.concurrent.{ExecutionContext, Future} + +class DevActionsController @Inject()(val controllerComponents: ControllerComponents, + val usersDAO: UsersDAO) + (implicit val ec: ExecutionContext) + extends BaseController { + + private case class UserData(username: String, password: String) + private implicit val userDataReads: Reads[UserData] = ( + (JsPath \ "username").read[String] and (JsPath \ "password").read[String] + )(UserData.apply) + + def createUser = Action.async(parse.json) { implicit request => + request.body.validate[UserData].fold( + errors => Future.successful(BadRequest(JsError.toJson(errors))), + userData => usersDAO.create(userData.username, userData.password) + .map { + case Left(UsersDAO.UsernameTaken) => BadRequest(Json.obj("error" -> "Username exists")) + case Left(_ex) => BadRequest(Json.obj("error" -> "User creation error")) + case Right(user) => Ok(Json.toJson(user)) + } + ) + } +} diff --git a/app/controllers/devel/DevRouter.scala b/app/controllers/devel/DevRouter.scala new file mode 100644 index 0000000..b2dadec --- /dev/null +++ b/app/controllers/devel/DevRouter.scala @@ -0,0 +1,24 @@ +package controllers.devel + +import play.api.Environment +import play.api.Mode.Dev +import play.api.mvc.* +import play.api.routing.Router.Routes +import play.api.routing.SimpleRouter +import play.api.routing.sird.{POST, UrlContext} + +import javax.inject.Inject + +class DevRouter @Inject()(environment: Environment, controller: DevActionsController) extends SimpleRouter { + override def routes: Routes = { + if (environment.mode == Dev) { + devRoutes + } else { + PartialFunction.empty + } + } + + private val devRoutes: PartialFunction[RequestHeader, Handler] = { + case POST(p"/users") => controller.createUser() + } +} diff --git a/app/dao/UsersDAO.scala b/app/dao/UsersDAO.scala index f3e1f88..7148960 100644 --- a/app/dao/UsersDAO.scala +++ b/app/dao/UsersDAO.scala @@ -1,5 +1,6 @@ package dao +import dao.UsersDAO.{UnexpectedError, UsernameTaken} import de.mkammerer.argon2.Argon2Factory import execution.CryptoExecutionContext import jakarta.inject.Inject @@ -9,23 +10,70 @@ import slick.jdbc.PostgresProfile.api.* import scala.concurrent.{ExecutionContext, Future} import models.User +import org.postgresql.util.{PSQLException, PSQLState} import schemas.Users -class UsersDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider, private val cryptoEC: CryptoExecutionContext)(implicit val ec: ExecutionContext) +import java.nio.charset.StandardCharsets.UTF_8 +import scala.util.Random + +class UsersDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider, private val cryptoEC: CryptoExecutionContext) + (using ec: ExecutionContext) extends HasDatabaseConfigProvider[PostgresProfile] { private val argon2 = Argon2Factory.create() + private object ArgonParameters { + val iterations = 3 + val parallelism = 1 + val memory = 65536 + } + + private final val dummyDigest = "$argon2i$v=19$m=65536,t=3,p=1$EB9QibLwzqkO7ne2bJoU/Q$ADkdyweXKylJORvWXbBVekURYaazkW/OplTq2wdN1So" + 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) + } yield (user, digest) + }.map { + case Some((user, digest)) => (Some(user), digest) + case None => (None, dummyDigest) + }.flatMap { (userOpt, digest) => + for { + validHash <- Future(argon2.verify(digest, password.getBytes(UTF_8)))(using cryptoEC) + opt = userOpt.flatMap(u => if validHash then Some(u) else None) + } yield opt + } } + + def create(username: String, password: String): Future[Either[UsersDAO.RegistrationError, User]] = { + Future(argonHash(password))(using cryptoEC) + .flatMap{ passwordDigest => + val gid = Random.alphanumeric.take(12).mkString + val user = User( + id = None, + gid = gid, + username = username, + passwordDigest = Some(passwordDigest) + ) + + db.run((Users returning Users) += user).map(Right(_)) + }.recover { + case ex: PSQLException if ex.getSQLState == PSQLState.UNIQUE_VIOLATION.getState => + Left(UsernameTaken) + case ex: Throwable => + Left(UnexpectedError(ex)) + } + } + + private def argonHash(data: String) = argon2.hash(ArgonParameters.iterations, ArgonParameters.memory, ArgonParameters.parallelism, data.getBytes(UTF_8)) } + +object UsersDAO { + sealed trait RegistrationError + case object UsernameTaken extends RegistrationError + case class UnexpectedError(cause: Throwable) extends RegistrationError +} \ No newline at end of file diff --git a/app/models/models.scala b/app/models/models.scala index 82b5575..85d8d27 100644 --- a/app/models/models.scala +++ b/app/models/models.scala @@ -5,6 +5,14 @@ import play.api.libs.json.{JsValue, Json, Writes} import java.time.Instant case class User(id: Option[Int], username: String, gid: String, passwordDigest: Option[String]) +object User { + given writes: Writes[User] = new Writes[User] { + override def writes(o: User): JsValue = Json.obj( + "gid" -> o.gid, + "username" -> o.username + ) + } +} case class AuthToken(id: Option[Int], userId: Int, token: String, createdAt: Instant, expiresAt: Instant) @@ -28,6 +36,7 @@ object Auction { "id" -> a.id, "name" -> a.name, "gid" -> a.gid, + "status" -> a.status.toString, "starting_price" -> a.startingPrice, "current_bid" -> a.currentBid, "current_bidder_id" -> a.currentBidderId, diff --git a/app/schemas/schemas.scala b/app/schemas/schemas.scala index 8f96afe..9a54c74 100644 --- a/app/schemas/schemas.scala +++ b/app/schemas/schemas.scala @@ -45,8 +45,8 @@ class AuctionsTable(tag: Tag) extends Table[Auction](tag, "auctions") { def status = column[AuctionStatus]("status") def gid = column[String]("gid", O.Unique) def authorId = column[Int]("author_id") - def startingPrice = column[BigDecimal]("starting_price") - def currentBid = column[Option[BigDecimal]]("current_bid") + def startingPrice = column[BigDecimal]("starting_price", O.SqlType("DECIMAL(20, 3)")) + def currentBid = column[Option[BigDecimal]]("current_bid", O.SqlType("DECIMAL(20, 3)")) def currentBidderId = column[Option[Int]]("current_bidder_id") def currentBidAt = column[Option[Instant]]("current_bid_at") def createdAt = column[Instant]("created_at") diff --git a/conf/routes b/conf/routes index 68008dc..726a52d 100644 --- a/conf/routes +++ b/conf/routes @@ -12,4 +12,7 @@ DELETE /logout controllers.authentication.SessionsControlle # Map static resources from the /public folder to the /assets URL path GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) -POST /auctions controllers.AuctionsController.create() \ No newline at end of file +GET /auctions controllers.AuctionsController.index() +POST /auctions controllers.AuctionsController.create() + +-> /-devel controllers.devel.DevRouter \ No newline at end of file