From cbfa019e0f56fa8654464fc72acf70a25a746013 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Mon, 28 Aug 2023 18:24:22 +0200 Subject: [PATCH 1/4] Clean up examples. Improve readme --- .gitignore | 2 + DEV.md | 8 +++- README.md | 16 +++++-- build.sc | 20 +++++++-- examples/form/src/Main.scala | 4 +- examples/form/test/src/FormApiSuite.scala | 39 ++++++------------ .../static/{imgs => images}/scala.png | Bin examples/html/resources/static/scala.png | Bin 12911 -> 0 bytes examples/html/src/Main.scala | 13 +++--- examples/json/src/Main.scala | 11 ++--- examples/json/src/requests.scala | 3 ++ examples/json/test/src/JsonApiSuite.scala | 32 +++++++------- examples/todo/README.md | 2 + examples/todo/src/TodosRepo.scala | 6 +++ sharaf/src/ba/sake/sharaf/Request.scala | 15 +++---- .../sake/sharaf/handlers/RoutesHandler.scala | 12 ++++-- sharaf/src/ba/sake/sharaf/package.scala | 15 +++++++ sharaf/src/ba/sake/sharaf/utils.scala | 11 +++++ 18 files changed, 135 insertions(+), 74 deletions(-) rename examples/html/resources/static/{imgs => images}/scala.png (100%) delete mode 100644 examples/html/resources/static/scala.png create mode 100644 sharaf/src/ba/sake/sharaf/utils.scala diff --git a/.gitignore b/.gitignore index 65ed077..e4a3a48 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ target/ *.class *.log +.idea/ + .bloop/ .metals/ .bsp/ diff --git a/DEV.md b/DEV.md index 19b5eb4..ce0f713 100644 --- a/DEV.md +++ b/DEV.md @@ -25,4 +25,10 @@ git push origin $VERSION # TODOs -- cookies ? \ No newline at end of file +- cookies ? + +- add Docker / Watchtower example + + + + diff --git a/README.md b/README.md index de7ba5f..7d2ff99 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # Sharaf +Simple, intuitive, batteries-included HTTP library. + ## Why sharaf? -**Simplicity and ease of use** is the main focus of sharaf. +Simplicity and ease of use is the main focus of sharaf. It is built on top of [undertow](https://undertow.io/). -This means you can use some awesome libraries built for undertow, like [pac4j](https://github.com/pac4j/undertow-pac4j) for security and similar. +This means you can use awesome libraries built for undertow, like [pac4j](https://github.com/pac4j/undertow-pac4j) for security and similar. Also, you can use undertow's lower level API, to use WebSockets for example. Sharaf bundles a set of libraries: @@ -15,8 +17,14 @@ Sharaf bundles a set of libraries: - [formson](./formson) for forms - [validson](./formson) for validation - [hepek-components](https://github.com/sake92/hepek) for HTML (with [scalatags](https://github.com/com-lihaoyi/scalatags)) - -There are a bunch of [examples](./examples) to get you started. +- [requests](https://github.com/com-lihaoyi/requests-scala) for firing HTTP requests + +## Examples +- handling [json](examples/json) +- handling [form data](examples/form) +- rendering [html](examples/html) and serving static files +- implementation of [todobackend.com](examples/todo) featuring CORS handling +- [OAuth2 login](examples/oauth2) with [Pac4J library](https://www.pac4j.org/) ## Misc diff --git a/build.sc b/build.sc index 304d04b..a3e2d98 100644 --- a/build.sc +++ b/build.sc @@ -11,7 +11,8 @@ object sharaf extends SharafPublishModule { def ivyDeps = Agg( ivy"io.undertow:undertow-core:2.3.7.Final", ivy"ba.sake::tupson:0.7.0", - ivy"ba.sake::hepek-components:0.13.0" + ivy"ba.sake::hepek-components:0.13.0", + ivy"com.lihaoyi::requests:0.8.0" ) def moduleDeps = Seq(querson, formson) @@ -25,6 +26,8 @@ object querson extends SharafPublishModule { def moduleDeps = Seq(validson) + def pomSettings = super.pomSettings().copy(description = "Simple query params library") + def ivyDeps = Agg( ivy"com.lihaoyi::fastparse:3.0.1" ) @@ -38,11 +41,12 @@ object formson extends SharafPublishModule { def moduleDeps = Seq(validson) +def pomSettings = super.pomSettings().copy(description = "Simple form binding library") + object test extends ScalaTests with SharafTestModule def ivyDeps = Agg( - ivy"com.lihaoyi::fastparse:3.0.1", - ivy"com.lihaoyi::requests:0.8.0" // TODO move to a separate module + ivy"com.lihaoyi::fastparse:3.0.1" ) } @@ -54,6 +58,8 @@ object validson extends SharafPublishModule { ivy"com.lihaoyi::sourcecode::0.3.0" ) + def pomSettings = super.pomSettings().copy(description = "Simple validation library") + object test extends ScalaTests with SharafTestModule } @@ -104,4 +110,12 @@ object examples extends mill.Module { def moduleDeps = Seq(sharaf) object test extends ScalaTests with SharafTestModule } + object oauth2 extends SharafCommonModule { + def moduleDeps = Seq(sharaf) + def ivyDeps = Agg( + ivy"org.pac4j:undertow-pac4j:5.0.1", + ivy"org.pac4j:pac4j-oauth:5.7.0", + ) + object test extends ScalaTests with SharafTestModule + } } diff --git a/examples/form/src/Main.scala b/examples/form/src/Main.scala index a5c9e32..04409c2 100644 --- a/examples/form/src/Main.scala +++ b/examples/form/src/Main.scala @@ -11,7 +11,7 @@ import ba.sake.validson.* @main def main: Unit = { - val server = FormApiServer(8181).server + val server = FormApiModule(8181).server server.start() val serverInfo = server.getListenerInfo().get(0) @@ -20,7 +20,7 @@ import ba.sake.validson.* } -class FormApiServer(port: Int) { +class FormApiModule(port: Int) { private val routes: Routes = { case POST() -> Path("form") => val req = Request.current.bodyForm[CreateCustomerForm].validateOrThrow val fileAsString = Files.readString(req.file) diff --git a/examples/form/test/src/FormApiSuite.scala b/examples/form/test/src/FormApiSuite.scala index df7ee2b..f39eb93 100644 --- a/examples/form/test/src/FormApiSuite.scala +++ b/examples/form/test/src/FormApiSuite.scala @@ -1,19 +1,19 @@ package demo -import scala.util.Random import io.undertow.Undertow + import ba.sake.formson.* import ba.sake.tupson.* -import ba.sake.sharaf.Resource +import ba.sake.sharaf.* class FormApiSuite extends munit.FunSuite { - override def munitFixtures = List(serverFixture) + override def munitFixtures = List(moduleFixture) test("customer can be created") { - val server = serverFixture() - val serverInfo = server.getListenerInfo().get(0) + val module = moduleFixture() + val serverInfo = module.server.getListenerInfo().get(0) val baseUrl = s"${serverInfo.getProtcol}:/${serverInfo.getAddress}" val exampleFile = @@ -23,39 +23,26 @@ class FormApiSuite extends munit.FunSuite { CreateCustomerForm("Meho", exampleFile, CreateAddressForm("street123ž"), List("hobby1", "hobby2")) val res = requests.post( s"$baseUrl/form", - data = formData2RequestsMultipart(reqBody.toFormDataMap()) + data = reqBody.toFormDataMap().toRequestsMultipart() ) assertEquals(res.statusCode, 200) - assertEquals(res.headers("content-type"), Seq("application/json")) // it returns JSON content.. val resBody = res.text.parseJson[CreateCustomerResponse] // this tests utf-8 encoding too :) assertEquals(resBody.street, "street123ž") assertEquals(resBody.fileContents, "This is a text file :)") } - // TODO extract into a separate requests-integration module - private def formData2RequestsMultipart(formDataMap: FormDataMap) = { - val multiItems = formDataMap.flatMap { case (key, values) => - values.map { - case FormValue.Str(value) => requests.MultiItem(key, value) - case FormValue.File(value) => requests.MultiItem(key, value, value.getFileName.toString) - case FormValue.ByteArray(value) => requests.MultiItem(key, value) - } - } - requests.MultiPart( - multiItems.toSeq* - ) - } + val moduleFixture = new Fixture[FormApiModule]("FormApiModule") { + private var module: FormApiModule = _ + + def apply() = module - val serverFixture = new Fixture[Undertow]("JsonApiServer") { - private var underlyingServer: Undertow = _ - def apply() = underlyingServer override def beforeEach(context: BeforeEach): Unit = - underlyingServer = FormApiServer(Random.between(1_024, 65_535)).server - underlyingServer.start() + module = FormApiModule(SharafUtils.getFreePort()) + module.server.start() override def afterEach(context: AfterEach): Unit = - underlyingServer.stop + module.server.stop() } } diff --git a/examples/html/resources/static/imgs/scala.png b/examples/html/resources/static/images/scala.png similarity index 100% rename from examples/html/resources/static/imgs/scala.png rename to examples/html/resources/static/images/scala.png diff --git a/examples/html/resources/static/scala.png b/examples/html/resources/static/scala.png deleted file mode 100644 index a237c157f306502d8af2c03ae8c9302f37f6ecf7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12911 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 Path("html") => - Response.withBody(MyPage) - case GET() -> Path("scala.png") => - val resource = Resource.fromClassPath("static/scala.png") + case GET() -> Path("images", imageName) => + val resource = Resource.fromClassPath(s"static/images/$imageName") Response.withBodyOpt(resource, "NotFound") + + case GET() -> Path() => + Response.withBody(MyPage) } val server = Undertow @@ -33,7 +34,7 @@ import scalatags.Text.all._ val MyPage = new HtmlPage { override def bodyContent: Frag = div( - "oppppppp", - img(src := "scala.png") + "Hello sharaf!", + img(src := "images/scala.png") ) } diff --git a/examples/json/src/Main.scala b/examples/json/src/Main.scala index b1bbd21..bbbf6cd 100644 --- a/examples/json/src/Main.scala +++ b/examples/json/src/Main.scala @@ -3,24 +3,23 @@ package demo import java.util.UUID import io.undertow.Undertow -import ba.sake.tupson.* import ba.sake.sharaf.* import ba.sake.sharaf.routing.* import ba.sake.sharaf.handlers.* -import ba.sake.querson.* +import ba.sake.tupson.* import ba.sake.validson.* @main def main: Unit = { - val server = JsonApiServer(8181).server + val server = JsonApiModule(8181).server server.start() val serverInfo = server.getListenerInfo().get(0) val url = s"${serverInfo.getProtcol}:/${serverInfo.getAddress}" - println(s"Started JsonApiServer at $url") + println(s"Started HTTP server at $url") } -class JsonApiServer(port: Int) { +class JsonApiModule(port: Int) { private var db = Seq.empty[CustomerRes] @@ -47,5 +46,3 @@ class JsonApiServer(port: Int) { .setHandler(ErrorHandler(RoutesHandler(routes), ErrorMapper.json)) .build() } - -case class UserQuery(name: Set[String]) derives QueryStringRW diff --git a/examples/json/src/requests.scala b/examples/json/src/requests.scala index d8bef1a..4f17602 100644 --- a/examples/json/src/requests.scala +++ b/examples/json/src/requests.scala @@ -2,6 +2,7 @@ package demo import ba.sake.tupson.JsonRW import ba.sake.validson.* +import ba.sake.querson.QueryStringRW case class CreateCustomerReq private (name: String, address: CreateAddressReq) derives JsonRW @@ -23,3 +24,5 @@ object CreateAddressReq: .derived[CreateAddressReq] .and(_.street, !_.isBlank, "must not be blank") .and(_.street, _.length >= 3, "must be >= 3") + +case class UserQuery(name: Set[String]) derives QueryStringRW diff --git a/examples/json/test/src/JsonApiSuite.scala b/examples/json/test/src/JsonApiSuite.scala index d4775bf..e284f52 100644 --- a/examples/json/test/src/JsonApiSuite.scala +++ b/examples/json/test/src/JsonApiSuite.scala @@ -1,19 +1,18 @@ package demo -import ba.sake.querson.* -import ba.sake.tupson.* -import scala.util.Random -import io.undertow.Undertow import ba.sake.sharaf.handlers.ProblemDetails import ba.sake.sharaf.handlers.ArgumentProblem +import ba.sake.sharaf.SharafUtils +import ba.sake.querson.* +import ba.sake.tupson.* class JsonApiSuite extends munit.FunSuite { - override def munitFixtures = List(serverFixture) + override def munitFixtures = List(moduleFixture) test("customers can be created and fetched") { - val server = serverFixture() - val serverInfo = server.getListenerInfo().get(0) + val module = moduleFixture() + val serverInfo = module.server.getListenerInfo().get(0) val baseUrl = s"${serverInfo.getProtcol}:/${serverInfo.getAddress}" // first GET -> empty @@ -80,8 +79,8 @@ class JsonApiSuite extends munit.FunSuite { } test("400 BadRequest when body not valid") { - val server = serverFixture() - val serverInfo = server.getListenerInfo().get(0) + val module = moduleFixture() + val serverInfo = module.server.getListenerInfo().get(0) val baseUrl = s"${serverInfo.getProtcol}:/${serverInfo.getAddress}" // blank name not allowed @@ -118,14 +117,17 @@ class JsonApiSuite extends munit.FunSuite { } - val serverFixture = new Fixture[Undertow]("JsonApiServer") { - private var underlyingServer: Undertow = _ - def apply() = underlyingServer + val moduleFixture = new Fixture[JsonApiModule]("JsonApiModule") { + private var module: JsonApiModule = _ + + def apply() = module + override def beforeEach(context: BeforeEach): Unit = - underlyingServer = JsonApiServer(Random.between(1_024, 65_535)).server - underlyingServer.start() + module = JsonApiModule(SharafUtils.getFreePort()) + module.server.start() + override def afterEach(context: AfterEach): Unit = - underlyingServer.stop + module.server.stop() } } diff --git a/examples/todo/README.md b/examples/todo/README.md index 77ac159..c86d4f2 100644 --- a/examples/todo/README.md +++ b/examples/todo/README.md @@ -1,4 +1,6 @@ +Sharaf's implementation of [Todo-Backend](https://todobackend.com) + Run from repo root: ```scala diff --git a/examples/todo/src/TodosRepo.scala b/examples/todo/src/TodosRepo.scala index 8daf753..95d832f 100644 --- a/examples/todo/src/TodosRepo.scala +++ b/examples/todo/src/TodosRepo.scala @@ -5,6 +5,7 @@ import ba.sake.tupson.JsonRW case class Todo(id: UUID, title: String, completed: Boolean, url: String, order: Option[Int]) derives JsonRW +// dont do this synchronized stuff at home! class TodosRepo { private var todosRef = List.empty[Todo] @@ -12,21 +13,26 @@ class TodosRepo { def getTodos(): List[Todo] = todosRef.synchronized { todosRef } + def getTodo(id: UUID): Todo = todosRef.synchronized { todosRef.find(_.id == id).get } + def add(req: CreateTodo): Todo = todosRef.synchronized { val id = UUID.randomUUID() val newTodo = Todo(id, req.title, false, s"http://localhost:8181/todos/${id}", req.order) todosRef = todosRef.appended(newTodo) newTodo } + def set(t: Todo): Unit = todosRef.synchronized { todosRef = todosRef.filterNot(_.id == t.id) :+ t } + def delete(id: UUID): Unit = todosRef.synchronized { todosRef = todosRef.filterNot(_.id == id) } + def deleteAll(): Unit = todosRef.synchronized { todosRef = List.empty } diff --git a/sharaf/src/ba/sake/sharaf/Request.scala b/sharaf/src/ba/sake/sharaf/Request.scala index 0b38628..2f01e51 100644 --- a/sharaf/src/ba/sake/sharaf/Request.scala +++ b/sharaf/src/ba/sake/sharaf/Request.scala @@ -2,14 +2,16 @@ package ba.sake.sharaf import java.nio.charset.StandardCharsets import scala.jdk.CollectionConverters.* -import ba.sake.tupson.* -import ba.sake.formson.* -import ba.sake.querson.* + import io.undertow.server.HttpServerExchange import io.undertow.server.handlers.form.FormData as UFormData import io.undertow.server.handlers.form.FormParserFactory import io.undertow.util.HttpString +import ba.sake.tupson.* +import ba.sake.formson.* +import ba.sake.querson.* + final class Request( private val ex: HttpServerExchange ) { @@ -32,13 +34,14 @@ final class Request( parserFactoryBuilder.setDefaultCharset("utf-8") parserFactoryBuilder.build } + lazy val bodyString: String = new String(ex.getInputStream.readAllBytes(), StandardCharsets.UTF_8) def bodyJson[T](using rw: JsonRW[T]): T = bodyString.parseJson[T] - def bodyForm[T <: Product](using rw: FormDataRW[T]): T = { + def bodyForm[T <: Product](using rw: FormDataRW[T]): T = // returns null if content-type is not suitable val parser = formBodyParserFactory.createParser(ex) Option(parser) match @@ -47,15 +50,13 @@ final class Request( val uFormData = parser.parseBlocking() val formData = Request.undertowFormData2Formson(uFormData) rw.parse("", formData) - } /* HEADERS */ - def headers: Map[HttpString, Seq[String]] = { + def headers: Map[HttpString, Seq[String]] = val hMap = ex.getRequestHeaders hMap.getHeaderNames.asScala.map { name => name -> hMap.get(name).asScala.toSeq }.toMap - } } diff --git a/sharaf/src/ba/sake/sharaf/handlers/RoutesHandler.scala b/sharaf/src/ba/sake/sharaf/handlers/RoutesHandler.scala index 30b72e9..9132afc 100644 --- a/sharaf/src/ba/sake/sharaf/handlers/RoutesHandler.scala +++ b/sharaf/src/ba/sake/sharaf/handlers/RoutesHandler.scala @@ -14,16 +14,18 @@ final class RoutesHandler private (routes: Routes) extends HttpHandler { } else { val request = Request.create(exchange) + given Request = request val reqParams = fillReqParams(exchange) val resOpt = routes.lift(reqParams) - // if no match, a 500 will be returned by Undertow resOpt match { case Some(res) => ResponseWritable.writeResponse(res, exchange) - case None => throw NotFoundException("") + case None => + // will be catched by ErrorMapper + throw NotFoundException("route not found") } } } @@ -33,7 +35,11 @@ final class RoutesHandler private (routes: Routes) extends HttpHandler { if exchange.getRelativePath.startsWith("/") then exchange.getRelativePath.drop(1) else exchange.getRelativePath val pathSegments = relPath.split("/") - val path = Path(pathSegments*) + + val path = + if pathSegments.size == 1 && pathSegments.head == "" + then Path() + else Path(pathSegments*) (exchange.getRequestMethod, path) } diff --git a/sharaf/src/ba/sake/sharaf/package.scala b/sharaf/src/ba/sake/sharaf/package.scala index d6090b8..b87005d 100644 --- a/sharaf/src/ba/sake/sharaf/package.scala +++ b/sharaf/src/ba/sake/sharaf/package.scala @@ -2,6 +2,21 @@ package ba.sake.sharaf import io.undertow.util.HttpString +import ba.sake.formson._ + type RequestParams = (HttpString, Path) type Routes = Request ?=> PartialFunction[RequestParams, Response[?]] + +// requests integration +extension (formDataMap: FormDataMap) + def toRequestsMultipart() = { + val multiItems = formDataMap.flatMap { case (key, values) => + values.map { + case FormValue.Str(value) => requests.MultiItem(key, value) + case FormValue.File(value) => requests.MultiItem(key, value, value.getFileName.toString) + case FormValue.ByteArray(value) => requests.MultiItem(key, value) + } + } + requests.MultiPart(multiItems.toSeq*) + } diff --git a/sharaf/src/ba/sake/sharaf/utils.scala b/sharaf/src/ba/sake/sharaf/utils.scala new file mode 100644 index 0000000..d9d96ef --- /dev/null +++ b/sharaf/src/ba/sake/sharaf/utils.scala @@ -0,0 +1,11 @@ +package ba.sake.sharaf + +import java.net.ServerSocket +import scala.util.Using + +object SharafUtils: + + def getFreePort(): Int = + Using.resource(new ServerSocket(0)) { ss => + ss.getLocalPort() + } From e7f7982a20f163537b634c09d3676dbd04150617 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Wed, 30 Aug 2023 08:49:46 +0200 Subject: [PATCH 2/4] Add OAuth2 example --- build.sc | 11 ++- examples/oauth2/src/AppModule.scala | 57 ++++++++++++++++ examples/oauth2/src/AppRoutes.scala | 67 +++++++++++++++++++ examples/oauth2/src/CustomCallbackLogic.scala | 32 +++++++++ examples/oauth2/src/CustomSecurityLogic.scala | 40 +++++++++++ examples/oauth2/src/Main.scala | 23 +++++++ examples/oauth2/src/SecurityConfig.scala | 33 +++++++++ examples/oauth2/src/SecurityService.scala | 36 ++++++++++ examples/oauth2/src/resources/logback.xml | 17 +++++ 9 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 examples/oauth2/src/AppModule.scala create mode 100644 examples/oauth2/src/AppRoutes.scala create mode 100644 examples/oauth2/src/CustomCallbackLogic.scala create mode 100644 examples/oauth2/src/CustomSecurityLogic.scala create mode 100644 examples/oauth2/src/Main.scala create mode 100644 examples/oauth2/src/SecurityConfig.scala create mode 100644 examples/oauth2/src/SecurityService.scala create mode 100644 examples/oauth2/src/resources/logback.xml diff --git a/build.sc b/build.sc index a3e2d98..d6228be 100644 --- a/build.sc +++ b/build.sc @@ -41,7 +41,7 @@ object formson extends SharafPublishModule { def moduleDeps = Seq(validson) -def pomSettings = super.pomSettings().copy(description = "Simple form binding library") + def pomSettings = super.pomSettings().copy(description = "Simple form binding library") object test extends ScalaTests with SharafTestModule @@ -113,9 +113,14 @@ object examples extends mill.Module { object oauth2 extends SharafCommonModule { def moduleDeps = Seq(sharaf) def ivyDeps = Agg( + ivy"ch.qos.logback:logback-classic:1.4.6", ivy"org.pac4j:undertow-pac4j:5.0.1", - ivy"org.pac4j:pac4j-oauth:5.7.0", + ivy"org.pac4j:pac4j-oauth:5.7.0" ) - object test extends ScalaTests with SharafTestModule + object test extends ScalaTests with SharafTestModule { + def ivyDeps = Agg( + ivy"no.nav.security:mock-oauth2-server:0.5.10" + ) + } } } diff --git a/examples/oauth2/src/AppModule.scala b/examples/oauth2/src/AppModule.scala new file mode 100644 index 0000000..8b66387 --- /dev/null +++ b/examples/oauth2/src/AppModule.scala @@ -0,0 +1,57 @@ +package demo + +import ba.sake.sharaf.* +import ba.sake.sharaf.handlers.* +import ba.sake.sharaf.routing.* + +import io.undertow.Handlers +import io.undertow.Undertow +import io.undertow.server.HttpHandler +import io.undertow.server.session.InMemorySessionManager +import io.undertow.server.session.SessionAttachmentHandler +import io.undertow.server.session.SessionCookieConfig +import org.pac4j.core.client.Clients +import org.pac4j.undertow.handler.CallbackHandler +import org.pac4j.undertow.handler.LogoutHandler +import org.pac4j.undertow.handler.SecurityHandler + +class AppModule(clients: Clients) { + + private val securityConfig = SecurityConfig(clients) + private val securityService = new SecurityService(securityConfig.pac4jConfig) + private val appRoutes = new AppRoutes(securityService) + + private val httpHandler: HttpHandler = locally { + val securityHandler = + SecurityHandler.build( + ErrorHandler( + RoutesHandler(appRoutes.routes) + ), + securityConfig.pac4jConfig, + securityConfig.clientNames.mkString(","), + null, + securityConfig.matchers, + new CustomSecurityLogic() + ) + + val pathHandler = Handlers + .path() + .addExactPath( + "/callback", + CallbackHandler.build(securityConfig.pac4jConfig, null, new CustomCallbackLogic()) + ) + .addExactPath("/logout", new LogoutHandler(securityConfig.pac4jConfig, "/")) + .addPrefixPath("/", securityHandler) + + new SessionAttachmentHandler( + pathHandler, + new InMemorySessionManager("SessionManager"), + new SessionCookieConfig() + ) + } + + val server = Undertow + .builder() + .addHttpListener(8181, "0.0.0.0", httpHandler) + .build() +} diff --git a/examples/oauth2/src/AppRoutes.scala b/examples/oauth2/src/AppRoutes.scala new file mode 100644 index 0000000..8865ca2 --- /dev/null +++ b/examples/oauth2/src/AppRoutes.scala @@ -0,0 +1,67 @@ +package demo + +import ba.sake.sharaf.* +import ba.sake.sharaf.routing.* +import ba.sake.hepek.html.HtmlPage +import scalatags.Text.all + +class AppRoutes(securityService: SecurityService) { + + val routes: Routes = { + + case GET() -> Path("protected") => + Response.withBody(Views.ProtectedPage) + + case GET() -> Path("login") => + Response.redirect("/") + + case GET() -> Path() => + Response.withBody(Views.IndexPage(securityService.currentUser)) + + case _ => + Response.withBody("Not found. ¯\\_(ツ)_/¯") + } + +} + +object Views { + + import scalatags.Text.all.* + + def IndexPage(userOpt: Option[CustomUserProfile]): HtmlPage = new { + override def pageContent: all.Frag = frag( + userOpt match { + case None => + frag( + div("Hello there!"), + div( + // any protected route would work here actually.. + // just need to set ?provider=GitHubClient + a(href := "/login?provider=GitHubClient")("Login with GitHub") + ) + ) + case Some(user) => + frag( + div( + s"Hello ${user.name} !" + ), + div( + a(href := "/protected")("Protected page") + ), + div( + a(href := "/logout")("Logout") + ) + ) + } + ) + } + + val ProtectedPage: HtmlPage = new { + override def pageContent: all.Frag = frag( + div("This is a protected page"), + div( + a(href := "/")("Home") + ) + ) + } +} diff --git a/examples/oauth2/src/CustomCallbackLogic.scala b/examples/oauth2/src/CustomCallbackLogic.scala new file mode 100644 index 0000000..fb4fad0 --- /dev/null +++ b/examples/oauth2/src/CustomCallbackLogic.scala @@ -0,0 +1,32 @@ +package demo + +import org.pac4j.core.config.Config +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.engine.DefaultCallbackLogic +import org.pac4j.core.profile.UserProfile +import org.pac4j.oauth.profile.github.GitHubProfile +import org.pac4j.oauth.profile.google2.Google2Profile + +class CustomCallbackLogic() extends DefaultCallbackLogic { + + override def saveUserProfile( + context: WebContext, + sessionStore: SessionStore, + config: Config, + userProfile: UserProfile, + saveProfileInSession: Boolean, + multiProfile: Boolean, + renewSession: Boolean + ): Unit = { + super.saveUserProfile(context, sessionStore, config, userProfile, saveProfileInSession, multiProfile, renewSession) + + userProfile match + case profile: GitHubProfile => + // save to database etc. whatever is needed + println(s"Saving profile to database: $profile") + case other => + throw new RuntimeException(s"Cant handle Pac4jUserProfile: $other") + + } +} diff --git a/examples/oauth2/src/CustomSecurityLogic.scala b/examples/oauth2/src/CustomSecurityLogic.scala new file mode 100644 index 0000000..854443c --- /dev/null +++ b/examples/oauth2/src/CustomSecurityLogic.scala @@ -0,0 +1,40 @@ +package demo + +import java.{util => ju} + +import scala.jdk.CollectionConverters.* +import scala.jdk.OptionConverters.* + +import org.pac4j.core.client.Client +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.engine.DefaultSecurityLogic +import org.pac4j.core.exception.http.HttpAction +import org.pac4j.core.exception.http.UnauthorizedAction + +class CustomSecurityLogic extends DefaultSecurityLogic { + + override protected def redirectToIdentityProvider( + context: WebContext, + sessionStore: SessionStore, + currentClients: ju.List[Client] + ): HttpAction = { + // Pac4J redirects to the FIRST CLIENT by default + // here we take the desired login method from the *query parameter* + // https://stackoverflow.com/questions/68428308/in-which-order-are-pac4j-client-used + val providerOpt = context.getRequestParameter("provider").toScala + providerOpt match + case None => + // we return 401 if not authenticated + // you *could* set a default client to be redirected to + return UnauthorizedAction() + case Some(clientName) => + currentClients.asScala.find(_.getName() == clientName) match + case None => + val action = UnauthorizedAction() + action.setContent("Unsupported provider") + action + case Some(client) => client.getRedirectionAction(context, sessionStore).get() + + } +} diff --git a/examples/oauth2/src/Main.scala b/examples/oauth2/src/Main.scala new file mode 100644 index 0000000..87e49ca --- /dev/null +++ b/examples/oauth2/src/Main.scala @@ -0,0 +1,23 @@ +package demo + +import org.pac4j.core.client.Clients +import org.pac4j.oauth.client.GitHubClient +import org.pac4j.oauth.client.Google2Client + +@main def main: Unit = { + + // configure your OAuth2 clients + // from pac4j's huge list https://www.pac4j.org/docs/clients/oauth.html + val githubClient = new GitHubClient("fa86622667cd00a837dc", "6b8026295971dd8b208f6d77babac72ffde395b4") + githubClient.setScope("read:user, user:email") + //val facebookClient = new FacebookClient(...) + + val clients = new Clients(s"http://localhost:8181/callback", githubClient) + + val server = AppModule(clients).server + server.start() + + val serverInfo = server.getListenerInfo().get(0) + val url = s"${serverInfo.getProtcol}:/${serverInfo.getAddress}" + println(s"Started HTTP server at $url") +} diff --git a/examples/oauth2/src/SecurityConfig.scala b/examples/oauth2/src/SecurityConfig.scala new file mode 100644 index 0000000..6dd2435 --- /dev/null +++ b/examples/oauth2/src/SecurityConfig.scala @@ -0,0 +1,33 @@ +package demo + +import scala.jdk.CollectionConverters.* + +import org.pac4j.core.client.Clients +import org.pac4j.core.matching.matcher.* +import org.pac4j.core.config.Config + +class SecurityConfig(clients: Clients) { + + private val publicRoutesMatcherName = "publicRoutesMatcher" + + val matchers = Set( + DefaultMatchers.SECURITYHEADERS, + publicRoutesMatcherName + ).mkString(",") + + val pac4jConfig = { + + val publicRoutesMatcher = new PathMatcher() + // exclude fixed paths + publicRoutesMatcher.excludePaths("/") + // exclude glob stuff* paths + Seq("/js", "/images").foreach(publicRoutesMatcher.excludeBranch) + + val config = new Config() + config.setClients(clients) + config.addMatcher(publicRoutesMatcherName, publicRoutesMatcher) + config + } + + val clientNames = pac4jConfig.getClients().getClients().asScala.map(_.getName()).toSeq +} diff --git a/examples/oauth2/src/SecurityService.scala b/examples/oauth2/src/SecurityService.scala new file mode 100644 index 0000000..bd9a940 --- /dev/null +++ b/examples/oauth2/src/SecurityService.scala @@ -0,0 +1,36 @@ +package demo + +import scala.jdk.OptionConverters.* + +import org.pac4j.core.config.Config +import org.pac4j.core.util.FindBest +import org.pac4j.undertow.context.{UndertowSessionStore, UndertowWebContext} + +import ba.sake.sharaf.Request + +class SecurityService(config: Config) { + + def currentUser(using req: Request): Option[CustomUserProfile] = { + val exchange = req.underlyingHttpServerExchange + + @annotation.nowarn + val sessionStore = FindBest.sessionStore(null, config, new UndertowSessionStore(exchange)) + + val profileManager = config.getProfileManagerFactory().apply(new UndertowWebContext(exchange), sessionStore) + + profileManager.getProfile().toScala.map { profile => + // val identityProvider = profile match .. + // val identityProviderId = profile.getId() + // find it in db by type+id for example + + CustomUserProfile(profile.getUsername()) + } + } + + def getCurrentUser(using req: Request): CustomUserProfile = + currentUser.getOrElse(throw NotAuthenticatedException()) +} + +case class CustomUserProfile(name: String) + +class NotAuthenticatedException extends RuntimeException diff --git a/examples/oauth2/src/resources/logback.xml b/examples/oauth2/src/resources/logback.xml new file mode 100644 index 0000000..3dd1afe --- /dev/null +++ b/examples/oauth2/src/resources/logback.xml @@ -0,0 +1,17 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file From 962923b27f493c92ecf66ff6692f809db2465f7d Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Wed, 30 Aug 2023 08:56:33 +0200 Subject: [PATCH 3/4] Fix tests --- DEV.md | 2 +- build.sc | 2 +- examples/form/test/src/FormApiSuite.scala | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/DEV.md b/DEV.md index ce0f713..50a90c6 100644 --- a/DEV.md +++ b/DEV.md @@ -26,7 +26,7 @@ git push origin $VERSION # TODOs - cookies ? - +- adapt query params to requests lib - add Docker / Watchtower example diff --git a/build.sc b/build.sc index d6228be..bc97f50 100644 --- a/build.sc +++ b/build.sc @@ -118,7 +118,7 @@ object examples extends mill.Module { ivy"org.pac4j:pac4j-oauth:5.7.0" ) object test extends ScalaTests with SharafTestModule { - def ivyDeps = Agg( + def ivyDeps = super.ivyDeps() ++ Agg( ivy"no.nav.security:mock-oauth2-server:0.5.10" ) } diff --git a/examples/form/test/src/FormApiSuite.scala b/examples/form/test/src/FormApiSuite.scala index f39eb93..789a748 100644 --- a/examples/form/test/src/FormApiSuite.scala +++ b/examples/form/test/src/FormApiSuite.scala @@ -1,7 +1,5 @@ package demo -import io.undertow.Undertow - import ba.sake.formson.* import ba.sake.tupson.* import ba.sake.sharaf.* From d03ef869a80737fca1564047334447f31e16189b Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Wed, 30 Aug 2023 09:00:34 +0200 Subject: [PATCH 4/4] Fix tests --- examples/oauth2/src/Main.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/oauth2/src/Main.scala b/examples/oauth2/src/Main.scala index 87e49ca..1346af4 100644 --- a/examples/oauth2/src/Main.scala +++ b/examples/oauth2/src/Main.scala @@ -8,7 +8,8 @@ import org.pac4j.oauth.client.Google2Client // configure your OAuth2 clients // from pac4j's huge list https://www.pac4j.org/docs/clients/oauth.html - val githubClient = new GitHubClient("fa86622667cd00a837dc", "6b8026295971dd8b208f6d77babac72ffde395b4") + // TODO fill your values here + val githubClient = new GitHubClient("KEY", "SECRET") githubClient.setScope("read:user, user:email") //val facebookClient = new FacebookClient(...)