Compare commits

...
3 Commits
Author SHA1 Message Date
artemis eadf793882 Auctions index and CORS filter 2026-09-03 00:02:34 +03:00
artemis f2ec6889cb Dev actions controller/router + user registration 2026-09-02 00:40:09 +03:00
artemis a6bbef1bf3 Reworked AuthenticatedAction 2026-09-01 00:42:38 +03:00
12 changed files with 161 additions and 16 deletions
+9 -1
View File
@@ -17,9 +17,17 @@ import scala.concurrent.{ExecutionContext, Future}
class AuctionsController @Inject()(val controllerComponents: ControllerComponents, class AuctionsController @Inject()(val controllerComponents: ControllerComponents,
val auctionsDAO: AuctionsDAO, val auctionsDAO: AuctionsDAO,
val authTokensDAO: AuthTokensDAO, val authTokensDAO: AuthTokensDAO,
authenticatedAction: AuthenticatedAction,
val auctionPublisher: ActorRef[AuctionPublisher.Command], val auctionPublisher: ActorRef[AuctionPublisher.Command],
implicit val ec: ExecutionContext) extends BaseController { implicit val ec: ExecutionContext) extends BaseController {
def create = Action.andThen(AuthenticatedAction(authTokensDAO)).async(parse.json) { implicit request => def index = authenticatedAction.async { implicit request =>
for {
auctions <- auctionsDAO.list_available(request.user)
json = Json.toJson(auctions)
} yield Ok(json)
}
def create = authenticatedAction.async(parse.json) { implicit request =>
val auctionCreateDataResult = request.body.validate[AuctionCreateData] val auctionCreateDataResult = request.body.validate[AuctionCreateData]
auctionCreateDataResult.fold( auctionCreateDataResult.fold(
errors => Future.successful(BadRequest(JsError.toJson(errors))), errors => Future.successful(BadRequest(JsError.toJson(errors))),
@@ -5,13 +5,15 @@ import jakarta.inject.Inject
import models.{AuthToken, User} import models.{AuthToken, User}
import play.api.libs.json.* import play.api.libs.json.*
import play.api.mvc.Results.Unauthorized import play.api.mvc.Results.Unauthorized
import play.api.mvc.{ActionRefiner, Request, Result, WrappedRequest} import play.api.mvc.{ActionBuilder, ActionRefiner, AnyContent, BodyParsers, Request, Result, WrappedRequest}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
class AuthenticatedRequest[A](val token: AuthToken, val user: User, request: Request[A]) extends WrappedRequest[A](request) class AuthenticatedRequest[A](val token: AuthToken, val user: User, request: Request[A]) extends WrappedRequest[A](request)
class AuthenticatedAction @Inject()(authTokensDao: AuthTokensDAO)(implicit val executionContext: ExecutionContext) extends ActionRefiner[Request, AuthenticatedRequest] { class AuthenticatedAction @Inject()(val parser: BodyParsers.Default, authTokensDao: AuthTokensDAO)(implicit val executionContext: ExecutionContext)
extends ActionRefiner[Request, AuthenticatedRequest]
with ActionBuilder[AuthenticatedRequest, AnyContent]{
override protected def refine[A](request: Request[A]): Future[Either[Result, AuthenticatedRequest[A]]] = { override protected def refine[A](request: Request[A]): Future[Either[Result, AuthenticatedRequest[A]]] = {
val authHeader = request.headers.get("Authorization") val authHeader = request.headers.get("Authorization")
.map(_.split(" ", 2)(1)) .map(_.split(" ", 2)(1))
@@ -35,5 +37,4 @@ class AuthenticatedAction @Inject()(authTokensDao: AuthTokensDAO)(implicit val e
case None => unauthenticatedRequestError case None => unauthenticatedRequestError
} }
} }
} }
@@ -22,7 +22,13 @@ implicit val tokenWrites: Writes[AuthToken] = (token: AuthToken) => Json.obj(
@Singleton @Singleton
class SessionsController @Inject()(controllerComponents: ControllerComponents, usersDAO: UsersDAO, authTokensDAO: AuthTokensDAO, implicit val ec: ExecutionContext) extends AbstractController(controllerComponents) { class SessionsController @Inject()(
controllerComponents: ControllerComponents,
usersDAO: UsersDAO,
authTokensDAO: AuthTokensDAO,
authenticatedAction: AuthenticatedAction)
(implicit val ec: ExecutionContext)
extends AbstractController(controllerComponents) {
def login() = Action.async(parse.json) { implicit request => def login() = Action.async(parse.json) { implicit request =>
val loginDataResult = request.body.validate[LoginData] val loginDataResult = request.body.validate[LoginData]
loginDataResult.fold( loginDataResult.fold(
@@ -41,7 +47,7 @@ class SessionsController @Inject()(controllerComponents: ControllerComponents, u
) )
} }
def logout = Action.andThen(new AuthenticatedAction(authTokensDAO)).async { request => def logout = authenticatedAction.async { request =>
authTokensDAO.destroy(request.token).map{ _ => Ok("") } authTokensDAO.destroy(request.token).map{ _ => Ok("") }
} }
} }
@@ -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))
}
)
}
}
+24
View File
@@ -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()
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ class LoggingFilter @Inject()(implicit val mat: Materializer, ec: ExecutionConte
val endTime = System.currentTimeMillis() val endTime = System.currentTimeMillis()
val requestTime = endTime - startTime val requestTime = endTime - startTime
logger.info(s"${requestHeader.method} ${requestHeader.uri} ${result.header.status} $requestTime") logger.info(s"${requestHeader.method} ${requestHeader.uri} ${result.header.status} ${requestTime}ms")
result result
} }
+11 -1
View File
@@ -2,7 +2,7 @@ package dao
import models.{Auction, AuctionStatus} import models.{Auction, AuctionStatus}
import schemas.Auctions import schemas.Auctions
import schemas.auctionStatusColumnType import schemas.given
import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider} import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider}
import slick.jdbc.PostgresProfile import slick.jdbc.PostgresProfile
import slick.jdbc.PostgresProfile.api.* import slick.jdbc.PostgresProfile.api.*
@@ -15,6 +15,16 @@ import scala.util.Random
class AuctionsDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext) class AuctionsDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext)
extends HasDatabaseConfigProvider[PostgresProfile] { extends HasDatabaseConfigProvider[PostgresProfile] {
def list_available(user: models.User): Future[Seq[Auction]] = {
db.run {
Auctions.filter(row => (row.status === AuctionStatus.Ready) && (row.authorId =!= user.id.get)).result
}
}
def list_created_by(user: models.User): Future[Seq[Auction]] = {
db.run(Auctions.filter(_.authorId === user.id).result)
}
def create(name: String, startingPrice: BigDecimal, author: models.User): Future[Auction] = { def create(name: String, startingPrice: BigDecimal, author: models.User): Future[Auction] = {
val auction = Auction( val auction = Auction(
id = None, name = name, id = None, name = name,
+53 -5
View File
@@ -1,5 +1,6 @@
package dao package dao
import dao.UsersDAO.{UnexpectedError, UsernameTaken}
import de.mkammerer.argon2.Argon2Factory import de.mkammerer.argon2.Argon2Factory
import execution.CryptoExecutionContext import execution.CryptoExecutionContext
import jakarta.inject.Inject import jakarta.inject.Inject
@@ -9,23 +10,70 @@ import slick.jdbc.PostgresProfile.api.*
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import models.User import models.User
import org.postgresql.util.{PSQLException, PSQLState}
import schemas.Users 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] { extends HasDatabaseConfigProvider[PostgresProfile] {
private val argon2 = Argon2Factory.create() 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]] = { def authenticate(username: String, password: String): Future[Option[User]] = {
val query = Users.filter(_.username === username).take(1).result.headOption val query = Users.filter(_.username === username).take(1).result.headOption
val userFuture = db.run(query) val userFuture = db.run(query)
userFuture.map { userOption => userFuture.map { userOption =>
for { for {
user <- userOption user <- userOption
digest <- user.passwordDigest digest <- user.passwordDigest
if argon2.verify(digest, password.getBytes("UTF-8")) } yield (user, digest)
} yield user }.map {
}(using cryptoEC) 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
} }
+9
View File
@@ -5,6 +5,14 @@ import play.api.libs.json.{JsValue, Json, Writes}
import java.time.Instant import java.time.Instant
case class User(id: Option[Int], username: String, gid: String, passwordDigest: Option[String]) 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) case class AuthToken(id: Option[Int], userId: Int, token: String, createdAt: Instant, expiresAt: Instant)
@@ -28,6 +36,7 @@ object Auction {
"id" -> a.id, "id" -> a.id,
"name" -> a.name, "name" -> a.name,
"gid" -> a.gid, "gid" -> a.gid,
"status" -> a.status.toString,
"starting_price" -> a.startingPrice, "starting_price" -> a.startingPrice,
"current_bid" -> a.currentBid, "current_bid" -> a.currentBid,
"current_bidder_id" -> a.currentBidderId, "current_bidder_id" -> a.currentBidderId,
+2 -2
View File
@@ -45,8 +45,8 @@ class AuctionsTable(tag: Tag) extends Table[Auction](tag, "auctions") {
def status = column[AuctionStatus]("status") def status = column[AuctionStatus]("status")
def gid = column[String]("gid", O.Unique) def gid = column[String]("gid", O.Unique)
def authorId = column[Int]("author_id") def authorId = column[Int]("author_id")
def startingPrice = column[BigDecimal]("starting_price") def startingPrice = column[BigDecimal]("starting_price", O.SqlType("DECIMAL(20, 3)"))
def currentBid = column[Option[BigDecimal]]("current_bid") def currentBid = column[Option[BigDecimal]]("current_bid", O.SqlType("DECIMAL(20, 3)"))
def currentBidderId = column[Option[Int]]("current_bidder_id") def currentBidderId = column[Option[Int]]("current_bidder_id")
def currentBidAt = column[Option[Instant]]("current_bid_at") def currentBidAt = column[Option[Instant]]("current_bid_at")
def createdAt = column[Instant]("created_at") def createdAt = column[Instant]("created_at")
+4
View File
@@ -2,9 +2,13 @@
play.http.filters=controllers.filters.Filters play.http.filters=controllers.filters.Filters
play.filters.enabled += "play.filters.cors.CORSFilter"
play.filters.csrf.header.bypassHeaders { play.filters.csrf.header.bypassHeaders {
X-Requested-With = "*" X-Requested-With = "*"
} }
play.filters.cors {
allowedOrigins = ["http://localhost:4321"]
}
play.modules.enabled += "mqttClient.Module" play.modules.enabled += "mqttClient.Module"
+3
View File
@@ -12,4 +12,7 @@ DELETE /logout controllers.authentication.SessionsControlle
# Map static resources from the /public folder to the /assets URL path # Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
GET /auctions controllers.AuctionsController.index()
POST /auctions controllers.AuctionsController.create() POST /auctions controllers.AuctionsController.create()
-> /-devel controllers.devel.DevRouter