From 1650d472d1f6ee220bd42a973e16fa8b81ca657d Mon Sep 17 00:00:00 2001 From: Override-6 Date: Thu, 26 Jan 2023 22:19:24 +0100 Subject: [PATCH] working on backend draft --- Core/src/org/tbasket/EndpointSetup.scala | 10 +-- .../org/tbasket/auth/Authentificator.scala | 79 +++++++++++++++++++ Core/src/org/tbasket/auth/JWTClient.scala | 23 ------ Core/src/org/tbasket/endpoint/Endpoint.scala | 60 ++++++++++++++ .../tbasket/handler/IncrementHandler.scala | 21 ----- .../org/tbasket/handler/LoginHandler.scala | 54 ++++++------- .../handler/LoginRequiredRequestHandler.scala | 19 ----- DB/src/org/tbasket/db/Database.scala | 11 +-- DB/src/org/tbasket/db/schemas/User.scala | 17 +--- .../src/org/tbasket/api/APIException.scala | 3 - Endpoint/src/org/tbasket/api/Endpoint.scala | 54 ------------- .../api/compute/APIRequestHandler.scala | 13 --- .../src/org/tbasket/jwt/JwtGenerator.scala | 16 ++-- JWTEmitter/src/org/tbasket/jwt/Main.scala | 2 +- build.sc | 60 +++++++------- 15 files changed, 214 insertions(+), 228 deletions(-) create mode 100644 Core/src/org/tbasket/auth/Authentificator.scala delete mode 100644 Core/src/org/tbasket/auth/JWTClient.scala create mode 100644 Core/src/org/tbasket/endpoint/Endpoint.scala delete mode 100644 Core/src/org/tbasket/handler/IncrementHandler.scala delete mode 100644 Core/src/org/tbasket/handler/LoginRequiredRequestHandler.scala delete mode 100644 Endpoint/src/org/tbasket/api/APIException.scala delete mode 100644 Endpoint/src/org/tbasket/api/Endpoint.scala delete mode 100644 Endpoint/src/org/tbasket/api/compute/APIRequestHandler.scala diff --git a/Core/src/org/tbasket/EndpointSetup.scala b/Core/src/org/tbasket/EndpointSetup.scala index c01dea7..b9efb44 100644 --- a/Core/src/org/tbasket/EndpointSetup.scala +++ b/Core/src/org/tbasket/EndpointSetup.scala @@ -2,9 +2,8 @@ package org.tbasket import io.getquill.{Literal, SqliteDialect} import io.getquill.context.qzio.ZioContext -import org.tbasket.api.Endpoint -import org.tbasket.handler.{IncrementHandler, LoginHandler} -import org.tbasket.auth.UserSessionHandler +import org.tbasket.endpoint.Endpoint +import org.tbasket.handler.LoginHandler import java.util.Properties @@ -16,10 +15,7 @@ object EndpointSetup: def setupEndpoint(config: Properties): Endpoint = Main.LOG.debug("Initializing API endpoint...") - val endpoint = createEndpoint(config) - endpoint.bind("counter")(IncrementHandler) - endpoint.bind("login")(new LoginHandler()) - endpoint + createEndpoint(config) private def createEndpoint(config: Properties): Endpoint = val port = config diff --git a/Core/src/org/tbasket/auth/Authentificator.scala b/Core/src/org/tbasket/auth/Authentificator.scala new file mode 100644 index 0000000..99a8e6b --- /dev/null +++ b/Core/src/org/tbasket/auth/Authentificator.scala @@ -0,0 +1,79 @@ +package org.tbasket.auth + +import io.circe.* +import io.circe.generic.auto.* +import io.circe.parser.* +import io.circe.syntax.* +import io.getquill.* +import io.getquill.context.qzio.ZioJdbcContext +import org.tbasket.InternalBasketServerException +import org.tbasket.db.schemas.User +import pdi.jwt.algorithms.JwtAsymmetricAlgorithm +import pdi.jwt.{JwtClaim, JwtZIOJson} +import zio.* +import zio.http.* +import zio.http.api.HttpCodec.Method +import zio.http.model.Status.{InternalServerError, Ok} +import zio.http.model.Version.Http_1_1 +import zio.http.model.{Headers, Method} + +import java.io.ByteArrayInputStream +import java.security.PublicKey +import java.util.UUID +import javax.sql.DataSource +import scala.collection.immutable.HashMap + +enum AuthentificatorError: + case ExpiredToken + case InvalidEmitterResponse + +case class JwtContent(uuid: UUID) + +class Authentificator(url: URL, key: PublicKey, algorithm: JwtAsymmetricAlgorithm) { + + private def defineCustomClaims(user: User): String = { + JwtContent(user.id).asJson.noSpaces.toString + } + + private def mkRequest(user: User): Request = { + val custom = defineCustomClaims(user) + Request(Body.fromString(custom), Headers.empty, Method.GET, url, Http_1_1, None) + } + + def requestJwt(user: User) = { + Client.request(mkRequest(user)) + .flatMap { + case Response(Ok, _, body, _, _) => + body.asString + case Response(InternalServerError, _, _, _, _) => + ZIO.fail(new InternalBasketServerException("internal server error")) + case _ => + ZIO.fail(new InternalBasketServerException("Received unknown response from emitter")) + } + } + + def validateAndGetUser(jwt: String) = { + for + //decoding token + claims <- ZIO.fromTry(JwtZIOJson.decode(jwt, key, Seq(algorithm))) + //ensure that the token is not expired (or else fail) + _ <- ZIO.attempt(claims.expiration) + .someOrFail("Received invalid jwt token (missing expiration date)") + .filterOrFail(_ <= java.lang.System.currentTimeMillis())("Expired token") + // + uuid <- ZIO.attempt(claims.content) + .mapAttempt(decode[JwtContent](_)) + .flatMap(ZIO.fromEither(_)) + .map(_.uuid) + + user <- ZIO.serviceWithZIO[ZioJdbcContext[_,_]] { ds => + import org.tbasket.db.Database.ctx.* + + run(quote { + query[User].filter(_.id == lift(uuid)) + }).map(_.headOption) + .someOrFail("uuid not found.") + } + yield user + } +} diff --git a/Core/src/org/tbasket/auth/JWTClient.scala b/Core/src/org/tbasket/auth/JWTClient.scala deleted file mode 100644 index 34ecbca..0000000 --- a/Core/src/org/tbasket/auth/JWTClient.scala +++ /dev/null @@ -1,23 +0,0 @@ -package org.tbasket.auth - -import io.netty.channel.ChannelHandlerContext -import org.tbasket.db.schemas.User -import pdi.jwt.JwtClaim -import zio.http.{Client, Request, URL} -import zio.* - -import java.io.ByteArrayInputStream - -class JWTClient(url: URL) { - - private def mkRequest(user: User): JwtClaim = { - Request.get(url) - .body - .write(new ByteArrayInputStream(Array.emptyByteArray)) - } - - def requestJwt(user: User): Task[JwtClaim] = { - } - - -} diff --git a/Core/src/org/tbasket/endpoint/Endpoint.scala b/Core/src/org/tbasket/endpoint/Endpoint.scala new file mode 100644 index 0000000..ee905de --- /dev/null +++ b/Core/src/org/tbasket/endpoint/Endpoint.scala @@ -0,0 +1,60 @@ +package org.tbasket.endpoint + +import io.getquill.{Literal, NamingStrategy, SqliteDialect} +import io.getquill.context.qzio.ZioContext +import org.apache.logging.log4j.LogManager +import org.tbasket.auth.Authentificator +import org.tbasket.endpoint.Endpoint.LOG +import org.tbasket.handler.LoginHandler +import zio.* +import zio.http.* +import zio.http.ServerConfig.LeakDetectionLevel +import zio.http.model.Method.{GET, POST} +import zio.http.model.Status + +import scala.collection.mutable +import zio.http.netty.client.ConnectionPool +import io.getquill.idiom.Idiom +import javax.sql.DataSource + +class Endpoint(port: Int): + + + // set generic required headers + private def applyGenerics(response: Response): Response = + response.withAccessControlAllowOrigin("*") + + private val app = Http.collectZIO[Request] { + case r@POST -> _ / "login" => + LoginHandler.post(r) + + case r@method -> path => + val ipInsights = r.remoteAddress + .map(ip => s": request received from $ip.") + .getOrElse("") + LOG.error( + s"Was unable to find a handler for request '$path' with method $method ${ipInsights}" + ) + ZIO.succeed(Response(Status.NotFound)) + }.map(applyGenerics) + + val run = + val config = ServerConfig.default + .port(port) + .leakDetection(LeakDetectionLevel.PARANOID) + + val serverConfigLayer = ServerConfig.live(config) + Server.install(app).flatMap { port => + LOG.info(s"Listening API entries on $port") + ZIO.never + }.provideSome[ZioContext[Idiom, NamingStrategy] & Authentificator]( + Scope.default, + serverConfigLayer, + ConnectionPool.fixed(4), + ClientConfig.default, + Server.live, + Client.live + ) + +object Endpoint: + final val LOG = LogManager.getLogger("API") diff --git a/Core/src/org/tbasket/handler/IncrementHandler.scala b/Core/src/org/tbasket/handler/IncrementHandler.scala deleted file mode 100644 index 592d0e1..0000000 --- a/Core/src/org/tbasket/handler/IncrementHandler.scala +++ /dev/null @@ -1,21 +0,0 @@ -package org.tbasket.handler - -import org.tbasket.Main -import org.tbasket.api.compute.APIRequestHandler -import zio.http.{Request, Response} - -import java.util.concurrent.atomic.AtomicInteger - -object IncrementHandler extends APIRequestHandler: - - private val counter = new AtomicInteger(0) - - def getCounter: Int = counter.get() - - override def get(request: Request): Response = - Response.json(s"{\"value\": ${counter.get()}}") - - override def post(request: Request): Response = - val i = counter.incrementAndGet() - Main.LOG.trace(s"Counter incremented : $i") - Response.ok diff --git a/Core/src/org/tbasket/handler/LoginHandler.scala b/Core/src/org/tbasket/handler/LoginHandler.scala index 85206ad..fce63f8 100644 --- a/Core/src/org/tbasket/handler/LoginHandler.scala +++ b/Core/src/org/tbasket/handler/LoginHandler.scala @@ -2,8 +2,7 @@ package org.tbasket.handler import io.getquill.* import io.getquill.context.qzio.ZioContext -import org.tbasket.api.compute.APIRequestHandler -import org.tbasket.auth.JWTClient +import org.tbasket.auth.Authentificator import org.tbasket.db.Database.ctx.* import org.tbasket.db.schemas.User import org.tbasket.handler.HandlerUtils.errorBody @@ -17,7 +16,8 @@ import zio.{ZEnvironment, ZIO, *} import java.sql.SQLException import java.util.UUID -import javax.sql.DataSource +import io.getquill._ +import io.getquill.context.ZioJdbc._ enum LoginError: case TokenNotFound(token: UUID) @@ -27,30 +27,28 @@ enum LoginError: case InternalError(t: Throwable) -class LoginHandler extends APIRequestHandler: +object LoginHandler: private def getUser(json: Json) = - val r = - ZIO.serviceWithZIO[ZioContext[SqliteDialect, Literal]] { ctx => - import ctx.* - for - mail <- - ZIO.fromEither(json.get[Str](JsonCursor.field("mail").isString).map(_.value)) - .mapError(InvalidRequest("Missing or invalid field mail", _)) - password <- - ZIO.fromEither(json.get[Str](JsonCursor.field("password").isString).map(_.value)) - .mapError(InvalidRequest("Missing or invalid field password", _)) - - result <- run(quote { // TODO use argon2id - User.schema.filter(usr => - usr.mailAddress == mail && usr.passwordHash == lift(password.hashCode) - ) - }).mapError(InternalError.apply) - yield result.headOption - } - r.someOrFail(InvalidPassword) + ZIO.serviceWithZIO[SqliteZioJdbcContext[_]] { ctx => + import ctx.* + for + mail <- + ZIO.fromEither(json.get[Str](JsonCursor.field("mail").isString).map(_.value)) + .mapError(InvalidRequest("Missing or invalid field mail", _)) + password <- + ZIO.fromEither(json.get[Str](JsonCursor.field("password").isString).map(_.value)) + .mapError(InvalidRequest("Missing or invalid field password", _)) + + result <- run(quote { // TODO use argon2id + query[User] + .filter(usr => usr.mailAddress == lift(mail)) + .filter(usr => usr.passwordHash == lift(password.hashCode)) + }).mapError(InternalError.apply) + yield result.headOption + }.someOrFail(InvalidPassword) - override def post(request: Request): Task[Response] = + override def post(request: Request) = val bindSession = for body <- request @@ -65,16 +63,14 @@ class LoginHandler extends APIRequestHandler: .mapError(InvalidRequest("Invalid JSON body", _)) user <- getUser(json) - jwt <- ZIO.serviceWithZIO[JWTClient](_.requestJwt(user)) + jwt <- ZIO.serviceWithZIO[Authentificator](_.requestJwt(user)) yield (user, jwt) - bindSession.map { sess => + bindSession.map { case (user, jwt) => Response( status = Status.Found, headers = Headers.location("/") ++ //login successful, go back to main page - Headers.setCookie(Cookie( - "JWT", "Jw" - )) + Headers.setCookie(Cookie("JWT", jwt)) //and set the token cookie ) } fold( { _ match diff --git a/Core/src/org/tbasket/handler/LoginRequiredRequestHandler.scala b/Core/src/org/tbasket/handler/LoginRequiredRequestHandler.scala deleted file mode 100644 index f3d5b37..0000000 --- a/Core/src/org/tbasket/handler/LoginRequiredRequestHandler.scala +++ /dev/null @@ -1,19 +0,0 @@ -package org.tbasket.handler - -import org.tbasket.api.compute.APIRequestHandler -import zio.* -import zio.http.model.Status -import zio.http.{Request, Response} - -trait LoginRequiredRequestHandler extends APIRequestHandler: - - override final def get: Task[Response] = - ZIO.succeed(Response(Status.MethodNotAllowed)) - - override final def post: Task[Response] = { - ZIO.succeed(Response(Status.MethodNotAllowed)) - } - - protected def authGet: Task[Response] - - protected def authPost: Task[Response] diff --git a/DB/src/org/tbasket/db/Database.scala b/DB/src/org/tbasket/db/Database.scala index e64b7b7..e4334fd 100644 --- a/DB/src/org/tbasket/db/Database.scala +++ b/DB/src/org/tbasket/db/Database.scala @@ -1,7 +1,8 @@ package org.tbasket.db import io.getquill.context.ZioJdbc.{DataSourceLayer, QuillZioExt} -import io.getquill.{Literal, SqliteZioJdbcContext} +import io.getquill.context.qzio.ZioContext +import io.getquill.{Literal, SnakeCase, SqliteDialect, SqliteZioJdbcContext} import org.sqlite.SQLiteDataSource import zio.* @@ -11,14 +12,10 @@ import javax.sql class Database(config: Properties): - private val source = new SQLiteDataSource() - - - source.setUrl(config.getProperty("database.url")) - val layer = ZLayer.succeed(source) + val layer = ZLayer.succeed(new SqliteZioJdbcContext(SnakeCase)) object Database: - val ctx = new SqliteZioJdbcContext(Literal) + val ctx = new SqliteZioJdbcContext(SnakeCase) import ctx.* diff --git a/DB/src/org/tbasket/db/schemas/User.scala b/DB/src/org/tbasket/db/schemas/User.scala index fa016ba..ddb3b3a 100644 --- a/DB/src/org/tbasket/db/schemas/User.scala +++ b/DB/src/org/tbasket/db/schemas/User.scala @@ -11,19 +11,4 @@ case class User( forename : String, passwordHash: Int, mailAddress : String - ) - -object User: - - import Database.ctx.* - - val schema = quote { - querySchema[User]( - "user", - _.id -> "id", - _.name -> "name", - _.forename -> "forename", - _.passwordHash -> "password_hash", - _.mailAddress -> "mail_address" - ) - } + ) \ No newline at end of file diff --git a/Endpoint/src/org/tbasket/api/APIException.scala b/Endpoint/src/org/tbasket/api/APIException.scala deleted file mode 100644 index b8d56e6..0000000 --- a/Endpoint/src/org/tbasket/api/APIException.scala +++ /dev/null @@ -1,3 +0,0 @@ -package org.tbasket.api - -class APIException(msg: String, cause: Throwable = null) extends Exception diff --git a/Endpoint/src/org/tbasket/api/Endpoint.scala b/Endpoint/src/org/tbasket/api/Endpoint.scala deleted file mode 100644 index b9afc25..0000000 --- a/Endpoint/src/org/tbasket/api/Endpoint.scala +++ /dev/null @@ -1,54 +0,0 @@ -package org.tbasket.api - -import org.apache.logging.log4j.LogManager -import org.tbasket.api.Endpoint.LOG -import org.tbasket.api.compute.APIRequestHandler -import zio.* -import zio.http.ServerConfig.LeakDetectionLevel -import zio.http.* -import zio.http.model.Method.{GET, POST} -import zio.http.model.Status - -import scala.collection.mutable - -class Endpoint(port: Int): - - private val handlers = mutable.HashMap.empty[String, APIRequestHandler] - - def bind(path: String)(handler: APIRequestHandler): Unit = - handlers.put(path, handler) - - // set generic required headers - private def transform(response: Response): Response = - response.withAccessControlAllowOrigin("*") - - private val app = Http.collectZIO[Request] { - case GET -> _ / path if handlers.contains(path) => - handlers(path).get.map(transform) - - case POST -> _ / path if handlers.contains(path) => - handlers(path).post.map(transform) - - case r@method -> path => - val ipInsights = r.remoteAddress - .map(ip => s": request received from $ip.") - .getOrElse("") - LOG.error( - s"Was unable to find a handler for request '$path' with method $method ${ipInsights}" - ) - ZIO.succeed(Response(Status.NotFound)) - } - - val run = - val config = ServerConfig.default - .port(port) - .leakDetection(LeakDetectionLevel.PARANOID) - - val configLayer = ServerConfig.live(config) - Server.install(app).flatMap { port => - LOG.info(s"Listening API entries on $port") - ZIO.never - }.provideSome(configLayer, Server.live) - -object Endpoint: - final val LOG = LogManager.getLogger("API") diff --git a/Endpoint/src/org/tbasket/api/compute/APIRequestHandler.scala b/Endpoint/src/org/tbasket/api/compute/APIRequestHandler.scala deleted file mode 100644 index 513e976..0000000 --- a/Endpoint/src/org/tbasket/api/compute/APIRequestHandler.scala +++ /dev/null @@ -1,13 +0,0 @@ -package org.tbasket.api.compute - -import zio.* -import zio.http.model.Status -import zio.http.{Request, Response} - -trait APIRequestHandler: - - def get: Task[Response] = - ZIO.succeed(Response(Status.MethodNotAllowed)) - - def post: Task[Response] = - ZIO.succeed(Response(Status.MethodNotAllowed)) diff --git a/JWTEmitter/src/org/tbasket/jwt/JwtGenerator.scala b/JWTEmitter/src/org/tbasket/jwt/JwtGenerator.scala index 34c0de7..18b9eee 100644 --- a/JWTEmitter/src/org/tbasket/jwt/JwtGenerator.scala +++ b/JWTEmitter/src/org/tbasket/jwt/JwtGenerator.scala @@ -3,6 +3,8 @@ package org.tbasket.jwt import pdi.jwt.* import pdi.jwt.algorithms.JwtAsymmetricAlgorithm import zio.* +import zio.http.model.HttpError +import zio.http.model.Status.InternalServerError import zio.http.{Request, Response} import zio.json.* import zio.json.ast.Json @@ -19,7 +21,7 @@ import javax.crypto.SecretKey import java.time.Duration class JwtGenerator(tokenLifespan: Duration, key: PrivateKey, algorithm: JwtAsymmetricAlgorithm): - + private def claims(content: String) = JwtClaim( expiration = Some(currentTimeMillis() + tokenLifespan.toMillis), issuedAt = Some(currentTimeMillis()), @@ -27,12 +29,14 @@ class JwtGenerator(tokenLifespan: Duration, key: PrivateKey, algorithm: JwtAsymm content = content ) - + def generateTokenResponse(request: Request): Task[Response] = for claims <- request.body.asString.map(claims) - jwt <- ZIO.attempt(JwtZIOJson.encode(claims, key, algorithm)).catchAll(e => { - ZIO.attempt(e.printStackTrace()).as("error") - }) + response <- ZIO.attempt(JwtZIOJson.encode(claims, key, algorithm)) + .map(Response.json) + .catchAll(e => { + ZIO.attempt(e.printStackTrace()).as(Response.status(InternalServerError)) + }) yield - Response.json(jwt) + response diff --git a/JWTEmitter/src/org/tbasket/jwt/Main.scala b/JWTEmitter/src/org/tbasket/jwt/Main.scala index 84ed5fb..1f78be9 100644 --- a/JWTEmitter/src/org/tbasket/jwt/Main.scala +++ b/JWTEmitter/src/org/tbasket/jwt/Main.scala @@ -50,7 +50,7 @@ object Main extends ZIOAppDefault: case ("-p" | "--port") :: value :: Nil => value } parsePort(port) <&> loadKey(keyFile) - + private val app = Http.collectZIO[Request] { case r @ Method.GET -> _ / "jwt" => ZIO.serviceWithZIO[JwtGenerator](_.generateTokenResponse(r)) diff --git a/build.sc b/build.sc index 3b55b1d..d01378c 100644 --- a/build.sc +++ b/build.sc @@ -1,23 +1,28 @@ import mill._, scalalib._, scalafmt._ trait ServerModule extends ScalaModule with ScalafmtModule { - - //override def scalacOptions = Seq("-explain") - - override final def scalaVersion = "3.2.0" - override def ivyDeps = Agg( - ivy"dev.zio::zio:2.0.6", - ivy"org.apache.logging.log4j:log4j-slf4j-impl:2.19.0" - ) + //override def scalacOptions = Seq("-explain") + + override final def scalaVersion = "3.2.0" + + override def ivyDeps = Agg( + ivy"dev.zio::zio:2.0.6", + ivy"org.apache.logging.log4j:log4j-slf4j-impl:2.19.0" + ) } + trait HttpModule extends ServerModule { - override def ivyDeps = super.ivyDeps() ++ Agg( - ivy"dev.zio::zio-http:0.0.3", - ivy"dev.zio::zio-json:0.4.2", - ivy"dev.zio::zio-streams:2.0.6", - ivy"com.github.jwt-scala::jwt-zio-json:9.1.2" - ) + override def ivyDeps = super.ivyDeps() ++ Agg( + ivy"dev.zio::zio-http:0.0.3", + ivy"dev.zio::zio-streams:2.0.6", + + ivy"io.circe::circe-core:0.14.3", + ivy"io.circe::circe-parser:0.14.3", + ivy"io.circe::circe-generic:0.14.3", + + ivy"com.github.jwt-scala::jwt-zio-json:9.1.2" + ) } /** @@ -25,29 +30,26 @@ trait HttpModule extends ServerModule { * */ object JWTEmitter extends HttpModule -/** - * module that handles the REST API endpoint - * */ -object Endpoint extends HttpModule - /** * Business layer of a server * */ -object Core extends ServerModule { - override def ivyDeps = super.ivyDeps() ++ Agg( - ivy"dev.zio::zio-json:0.4.2", - ) - - override def moduleDeps = Seq(Endpoint, DB) +object Core extends HttpModule { //also handles http + override def ivyDeps = super.ivyDeps() ++ Agg( + ivy"io.circe::circe-core:0.15.0-M1", + ivy"io.circe::circe-parser:0.14.3", + ivy"io.circe::circe-generic:0.14.3", + ) + + override def moduleDeps = Seq(DB) } /** * Database module * */ object DB extends ServerModule { - override def ivyDeps = super.ivyDeps() ++ Agg( - ivy"io.getquill::quill-jdbc-zio:4.6.0", + override def ivyDeps = super.ivyDeps() ++ Agg( + ivy"io.getquill::quill-jdbc-zio:4.6.0", - ivy"org.xerial:sqlite-jdbc:3.40.0.0", - ) + ivy"org.xerial:sqlite-jdbc:3.40.0.0", + ) } \ No newline at end of file