diff --git a/shotgun/.bsp/sbt.json b/shotgun/.bsp/sbt.json index b7bca3d..c5b0993 100644 --- a/shotgun/.bsp/sbt.json +++ b/shotgun/.bsp/sbt.json @@ -1 +1 @@ -{"name":"sbt","version":"1.9.1","bspVersion":"2.1.0-M1","languages":["scala"],"argv":["/Users/willfreeman/Library/Caches/Coursier/arc/https/github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.10%252B7/OpenJDK17U-jdk_x64_mac_hotspot_17.0.10_7.tar.gz/jdk-17.0.10+7/Contents/Home/bin/java","-Xms100m","-Xmx100m","-classpath","/Users/willfreeman/Library/Caches/Coursier/arc/https/github.com/sbt/sbt/releases/download/v1.8.2/sbt-1.8.2.zip/sbt/bin/sbt-launch.jar","-Dsbt.script=/Users/willfreeman/Library/Caches/Coursier/arc/https/github.com/sbt/sbt/releases/download/v1.8.2/sbt-1.8.2.zip/sbt/bin/sbt","xsbt.boot.Boot","-bsp"]} \ No newline at end of file +{"name":"sbt","version":"1.9.1","bspVersion":"2.1.0-M1","languages":["scala"],"argv":["/Users/willfreeman/Library/Java/JavaVirtualMachines/corretto-11.0.19/Contents/Home/bin/java","-Xms100m","-Xmx100m","-classpath","/Users/willfreeman/Library/Application Support/JetBrains/IdeaIC2024.1/plugins/Scala/launcher/sbt-launch.jar","-Dsbt.script=/Users/willfreeman/Library/Application%20Support/Coursier/bin/sbt","xsbt.boot.Boot","-bsp"]} \ No newline at end of file diff --git a/shotgun/build.sbt b/shotgun/build.sbt index dbe9dda..dc121fb 100644 --- a/shotgun/build.sbt +++ b/shotgun/build.sbt @@ -14,6 +14,10 @@ lazy val root = (project in file(".")) val PekkoVersion = "1.0.3" val PekkoHttpVersion = "1.0.1" libraryDependencies ++= Seq( + "ch.qos.logback" % "logback-classic" % "1.5.6", + "org.slf4j" % "slf4j-api" % "2.0.12", + "com.auth0" % "jwks-rsa" % "0.22.1", + "com.github.jwt-scala" % "jwt-json-common_native0.4_2.12" % "10.0.1", "org.apache.pekko" %% "pekko-actor-typed" % PekkoVersion, "org.apache.pekko" %% "pekko-stream" % PekkoVersion, "org.apache.pekko" %% "pekko-http" % PekkoHttpVersion, diff --git a/shotgun/src/main/resources/logback.xml b/shotgun/src/main/resources/logback.xml new file mode 100644 index 0000000..b27731b --- /dev/null +++ b/shotgun/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + %d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n + + + + + + + + + diff --git a/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala b/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala index b07f67a..5783b0a 100644 --- a/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala +++ b/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala @@ -14,6 +14,7 @@ import org.apache.pekko.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ import org.apache.pekko.http.scaladsl.server.RejectionHandler import services.DeleteObjectRequest import services.DeleteObjectRequestJsonProtocol._ +import services.JwtAuthenticator.{authenticated, hasPermissions} import spray.json.DefaultJsonProtocol._ import services.ImageSubmissionJsonProtocol._ @@ -21,11 +22,14 @@ import java.net.URLEncoder import java.nio.charset.StandardCharsets import scala.concurrent.ExecutionContextExecutor import scala.io.StdIn +import org.slf4j.LoggerFactory object ShotgunServer { def main(args: Array[String]): Unit = { + val logger = LoggerFactory.getLogger(getClass) + implicit val system: ActorSystem = ActorSystem("my-system") implicit val executionContext: ExecutionContextExecutor = system.dispatcher val logging = Logging(system, getClass) @@ -82,35 +86,34 @@ object ShotgunServer { } }, path("presigned-urls") { - get { - parameters("count".as[Int], "contentType".as[String], "author".as[String]) { (imageCount, contentType, author) => - if (imageCount > 5) - complete(StatusCodes.BadRequest, "Cannot request more than 5 presigned URLs at a time") - else { - val urls = awsClient.getMultiplePutPresignedUrls("deflock-photo-uploads", imageCount, contentType, author, 5) - complete(urls) + hasPermissions(List("photo:upload")) { claim => + get { + parameters("count".as[Int], "contentType".as[String], "author".as[String]) { (imageCount, contentType, author) => + if (imageCount > 5) + complete(StatusCodes.BadRequest, "Cannot request more than 5 presigned URLs at a time") + else { + val urls = awsClient.getMultiplePutPresignedUrls("deflock-photo-uploads", imageCount, contentType, author, 5) + complete(urls) + } } } } }, path("user-submissions") { - get { - val submissions = awsClient.getAllObjects("deflock-photo-uploads") - complete(submissions) - } - }, - path("delete-object") { - post { - entity(as[DeleteObjectRequest]) { request => - val res = awsClient.deleteObject("deflock-photo-uploads", request.objectKey) - complete(res) + hasPermissions(List("photo:view")) { claim => + get { + val submissions = awsClient.getAllObjects("deflock-photo-uploads") + complete(submissions) } } }, - path("oauth2" / "callback") { - get { - parameters(Symbol("code").?) { (code) => - complete(HttpEntity(ContentTypes.`text/html(UTF-8)`, "

Say hello to Pekko HTTP

Code: " + code.getOrElse("None") + "

")) + path("delete-object") { + hasPermissions(List("photo:delete")) { claim => + post { + entity(as[DeleteObjectRequest]) { request => + val res = awsClient.deleteObject("deflock-photo-uploads", request.objectKey) + complete(res) + } } } } diff --git a/shotgun/src/main/scala/services/JwtAuthenticator.scala b/shotgun/src/main/scala/services/JwtAuthenticator.scala new file mode 100644 index 0000000..9bc16fd --- /dev/null +++ b/shotgun/src/main/scala/services/JwtAuthenticator.scala @@ -0,0 +1,88 @@ +package services + +import org.apache.pekko.http.scaladsl.server.{Directive1, Directives} +import pdi.jwt.{Jwt, JwtAlgorithm, JwtOptions} +import spray.json.JsValue +import spray.json.JsonParser +import com.auth0.jwk.{JwkProvider, JwkProviderBuilder} + +import java.security.PublicKey +import java.security.interfaces.RSAPublicKey +import scala.util.{Failure, Success, Try} +import java.util.concurrent.TimeUnit +import org.slf4j.{Logger, LoggerFactory} + +import java.util.Base64 + +object JwtAuthenticator extends Directives { + val logger: Logger = LoggerFactory.getLogger(getClass) + val domain = "https://deflock.us.auth0.com" + val jwkProvider: JwkProvider = new JwkProviderBuilder(domain) + .cached(10, 24, TimeUnit.HOURS) // Cache up to 10 keys for 24 hours + .build() + + def getPublicKey(kid: String): Option[PublicKey] = { + Try { + val jwk = jwkProvider.get(kid) + jwk.getPublicKey.asInstanceOf[RSAPublicKey] + } match { + case Success(publicKey) => Some(publicKey) + case Failure(_) => None + } + } + + def validateToken(token: String): Option[JsValue] = { + val parts = token.split("\\.") + if (parts.length != 3) { + return None + } + + val rawHeader = new String(Base64.getUrlDecoder.decode(parts(0))) + val headerJson = JsonParser(rawHeader) + val kid = headerJson.asJsObject.fields.get("kid").flatMap { + case spray.json.JsString(k) => Some(k) + case _ => None + } + + kid.flatMap(getPublicKey) match { + case Some(publicKey) => + Jwt.decode(token, publicKey, Seq(JwtAlgorithm.RS256), JwtOptions.DEFAULT) match { + case Success(claim) => + Some(JsonParser(claim.content)) + case Failure(_) => + None + } + case None => + None + } + } + + def authenticated: Directive1[JsValue] = { + optionalHeaderValueByName("Authorization").flatMap { + case Some(header) if header.startsWith("Bearer ") => + val token = header.substring("Bearer ".length) + validateToken(token) match { + case Some(claim) => provide(claim) + case None => complete((401, "Invalid token")) + } + case _ => + complete((401, "Missing or malformed Authorization header")) + } + } + + def hasPermissions(requiredPermissions: List[String]): Directive1[JsValue] = { + authenticated.flatMap { claim => + val permissions = claim.asJsObject.fields.get("permissions").flatMap { + case spray.json.JsArray(elements) => Some(elements.collect { case spray.json.JsString(permission) => permission }) + case _ => None + } + + permissions match { + case Some(userPermissions) if requiredPermissions.forall(userPermissions.contains) => + provide(claim) + case _ => + complete((403, "Insufficient permissions")) + } + } + } +} diff --git a/webapp/src/main.ts b/webapp/src/main.ts index 35c07a5..909ac48 100644 --- a/webapp/src/main.ts +++ b/webapp/src/main.ts @@ -16,6 +16,7 @@ const auth0 = createAuth0({ clientId: "IEBa7ckgWrMGErTWXA8Z9q91hre7uII2", authorizationParams: { redirect_uri: window.location.origin, + audience: "https://deflock.me/api", }, cacheLocation: 'localstorage', })