-
Notifications
You must be signed in to change notification settings - Fork 74
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Delegate S3 file validation and creation endpoints (#4998)
* Delegate file operation * Add delegate file route * JWS token issuing / verification logic * Parse RSA private key, generate public, wire up route * Use custom header for signature, provide storage id optionally, add integration test * Add delegation to AWS config * Add/test endpoint for registration using delegation path * RSA config docs, tidy errors * Refactor
- Loading branch information
Showing
22 changed files
with
513 additions
and
28 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
125 changes: 125 additions & 0 deletions
125
...cala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/DelegateFilesRoutes.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
package ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes | ||
|
||
import akka.http.scaladsl.model.StatusCodes.{Created, OK} | ||
import akka.http.scaladsl.model.Uri | ||
import akka.http.scaladsl.server._ | ||
import cats.effect.IO | ||
import cats.syntax.all._ | ||
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileRejection._ | ||
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model._ | ||
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes.DelegateFilesRoutes._ | ||
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.{FileResource, Files} | ||
import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StoragesConfig.ShowFileLocation | ||
import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri | ||
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution | ||
import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering | ||
import ch.epfl.bluebrain.nexus.delta.sdk.{IndexingAction, IndexingMode} | ||
import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck | ||
import ch.epfl.bluebrain.nexus.delta.sdk.circe.{CirceMarshalling, CirceUnmarshalling} | ||
import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._ | ||
import ch.epfl.bluebrain.nexus.delta.sdk.directives.{AuthDirectives, DeltaSchemeDirectives, ResponseToJsonLd} | ||
import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities | ||
import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller | ||
import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, IdSegment} | ||
import io.circe.generic.extras.Configuration | ||
import io.circe.generic.extras.semiauto.deriveConfiguredDecoder | ||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} | ||
import io.circe.syntax.EncoderOps | ||
import io.circe.{Decoder, Encoder, Json} | ||
import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ | ||
import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef | ||
|
||
final class DelegateFilesRoutes( | ||
identities: Identities, | ||
aclCheck: AclCheck, | ||
files: Files, | ||
tokenIssuer: TokenIssuer, | ||
index: IndexingAction.Execute[File], | ||
schemeDirectives: DeltaSchemeDirectives | ||
)(implicit | ||
baseUri: BaseUri, | ||
cr: RemoteContextResolution, | ||
ordering: JsonKeyOrdering, | ||
showLocation: ShowFileLocation | ||
) extends AuthDirectives(identities, aclCheck) | ||
with CirceUnmarshalling | ||
with CirceMarshalling { self => | ||
|
||
import schemeDirectives._ | ||
|
||
def routes: Route = | ||
baseUriPrefix(baseUri.prefix) { | ||
pathPrefix("delegate" / "files") { | ||
extractCaller { implicit caller => | ||
projectRef { project => | ||
concat( | ||
pathPrefix("validate") { | ||
(pathEndOrSingleSlash & post) { | ||
parameter("storage".as[IdSegment].?) { storageId => | ||
entity(as[FileDescription]) { desc => | ||
emit(OK, validateFileDetails(project, storageId, desc).attemptNarrow[FileRejection]) | ||
} | ||
} | ||
} | ||
}, | ||
(pathEndOrSingleSlash & post) { | ||
(parameter("storage".as[IdSegment].?) & indexingMode) { (storageId, mode) => | ||
entity(as[Json]) { jwsPayload => | ||
emit( | ||
Created, | ||
registerDelegatedFile(jwsPayload, project, storageId, mode) | ||
.attemptNarrow[FileRejection]: ResponseToJsonLd | ||
) | ||
} | ||
} | ||
} | ||
) | ||
} | ||
} | ||
} | ||
} | ||
|
||
private def validateFileDetails(project: ProjectRef, storageId: Option[IdSegment], desc: FileDescription)(implicit | ||
c: Caller | ||
) = | ||
for { | ||
delegationResp <- files.delegate(project, desc, storageId) | ||
jwsPayload <- tokenIssuer.issueJWSPayload(delegationResp.asJson) | ||
} yield jwsPayload | ||
|
||
private def registerDelegatedFile( | ||
jwsPayload: Json, | ||
project: ProjectRef, | ||
storageId: Option[IdSegment], | ||
mode: IndexingMode | ||
)(implicit c: Caller): IO[FileResource] = | ||
for { | ||
originalPayload <- tokenIssuer.verifyJWSPayload(jwsPayload) | ||
delegationResponse <- IO.fromEither(originalPayload.as[DelegationResponse]) | ||
fileId = FileId(delegationResponse.id, project) | ||
fileResource <- | ||
files.registerFile( | ||
fileId, | ||
storageId, | ||
delegationResponse.metadata, | ||
delegationResponse.path.path, | ||
None, | ||
None | ||
) | ||
_ <- index(project, fileResource, mode) | ||
} yield fileResource | ||
|
||
} | ||
|
||
object DelegateFilesRoutes { | ||
|
||
final case class DelegationResponse(bucket: String, id: Iri, path: Uri, metadata: Option[FileCustomMetadata]) | ||
|
||
object DelegationResponse { | ||
implicit val enc: Encoder[DelegationResponse] = deriveEncoder | ||
implicit val dec: Decoder[DelegationResponse] = deriveDecoder | ||
} | ||
|
||
implicit private val config: Configuration = Configuration.default | ||
implicit val dec: Decoder[FileDescription] = deriveConfiguredDecoder[FileDescription] | ||
} |
80 changes: 80 additions & 0 deletions
80
...c/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/TokenIssuer.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
package ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes | ||
|
||
import cats.effect.{Clock, IO} | ||
import ch.epfl.bluebrain.nexus.delta.kernel.Logger | ||
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileRejection.{InvalidJWSPayload, JWSSignatureExpired} | ||
import com.nimbusds.jose.crypto.{RSASSASigner, RSASSAVerifier} | ||
import com.nimbusds.jose.jwk.RSAKey | ||
import com.nimbusds.jose.util.JSONObjectUtils | ||
import com.nimbusds.jose.{JWSAlgorithm, JWSHeader, JWSObjectJSON, Payload} | ||
import io.circe.{parser, Json, Printer} | ||
|
||
import java.security.KeyFactory | ||
import java.security.interfaces.{RSAPrivateCrtKey, RSAPublicKey} | ||
import java.security.spec.{PKCS8EncodedKeySpec, RSAPublicKeySpec} | ||
import java.util.Base64 | ||
import scala.concurrent.duration.FiniteDuration | ||
import scala.jdk.CollectionConverters.ListHasAsScala | ||
import scala.util.Try | ||
|
||
class TokenIssuer(key: RSAKey, tokenValidity: FiniteDuration)(implicit clock: Clock[IO]) { | ||
private val signer = new RSASSASigner(key) | ||
private val verifier = new RSASSAVerifier(key.toPublicJWK) | ||
private val TokenValiditySeconds = tokenValidity.toSeconds | ||
private val log = Logger[TokenIssuer] | ||
|
||
def issueJWSPayload(payloadToSign: Json): IO[Json] = | ||
for { | ||
now <- clock.realTimeInstant | ||
jwsObject = mkJWSObject(payloadToSign) | ||
_ <- IO.delay(jwsObject.sign(mkJWSHeader(now.getEpochSecond + TokenValiditySeconds), signer)) | ||
serialized <- IO.delay(jwsObject.serializeFlattened()) | ||
json <- IO.fromEither(parser.parse(serialized)) | ||
} yield json | ||
|
||
def verifyJWSPayload(payload: Json): IO[Json] = | ||
for { | ||
jwsObject <- IO.delay(JWSObjectJSON.parse(payload.toString())) | ||
sig <- IO.fromOption(jwsObject.getSignatures.asScala.headOption)(InvalidJWSPayload) | ||
_ <- IO.delay(sig.verify(verifier)) | ||
objectPayload = jwsObject.getPayload.toString | ||
originalPayload <- IO.fromEither(parser.parse(objectPayload)) | ||
_ <- log.info(s"Original payload parsed for token: $originalPayload") | ||
now <- clock.realTimeInstant | ||
exp <- IO.delay(sig.getHeader.getCustomParam("exp").asInstanceOf[Long]) | ||
_ <- IO.raiseWhen(now.getEpochSecond > exp)(JWSSignatureExpired(originalPayload)) | ||
} yield originalPayload | ||
|
||
private def mkJWSHeader(expSeconds: Long): JWSHeader = | ||
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID).customParam("exp", expSeconds).build() | ||
|
||
private def mkJWSObject(payload: Json) = new JWSObjectJSON(mkPayload(payload)) | ||
|
||
private def mkPayload(raw: Json) = { | ||
val jsonObjectMap = JSONObjectUtils.parse(raw.printWith(Printer.noSpacesSortKeys)) | ||
new Payload(jsonObjectMap) | ||
} | ||
} | ||
|
||
object TokenIssuer { | ||
|
||
def generateRSAKeyFromPrivate(privateKey: RSAPrivateCrtKey): RSAKey = { | ||
val publicKeySpec: RSAPublicKeySpec = new RSAPublicKeySpec(privateKey.getModulus, privateKey.getPublicExponent) | ||
val kf = KeyFactory.getInstance("RSA") | ||
val publicKey = kf.generatePublic(publicKeySpec).asInstanceOf[RSAPublicKey] | ||
new RSAKey.Builder(publicKey).privateKey(privateKey).build() | ||
} | ||
|
||
def parseRSAPrivateKey(raw: String): Try[RSAPrivateCrtKey] = Try { | ||
val keyStripped = raw | ||
.replace("-----END PRIVATE KEY-----", "") | ||
.replace("-----BEGIN PRIVATE KEY-----", "") | ||
.replace("\n", "") | ||
val keyStrippedDecoded = Base64.getDecoder.decode(keyStripped) | ||
|
||
val keySpec = new PKCS8EncodedKeySpec(keyStrippedDecoded) | ||
val kf = KeyFactory.getInstance("RSA") | ||
kf.generatePrivate(keySpec).asInstanceOf[RSAPrivateCrtKey] | ||
} | ||
|
||
} |
Oops, something went wrong.