diff --git a/build.sbt b/build.sbt index 6dfff15f..bdee60db 100644 --- a/build.sbt +++ b/build.sbt @@ -23,7 +23,7 @@ def scalaModuleProject(modName: String): Project = { .settings( basicSettings, moduleName := modName, - crossScalaVersions := Seq(scala212, scala213) + crossScalaVersions := Seq(scala212, scala213, scala31) ) } @@ -174,7 +174,7 @@ lazy val `mauth-authenticator-akka-http` = scalaModuleProject("mauth-authenticat ) lazy val `mauth-authenticator-http4s` = (project in file("modules/mauth-authenticator-http4s")) // don't need to cross-compile - .dependsOn(`mauth-authenticator-scala`, `mauth-test-utils` % "test") + .dependsOn(`mauth-signer-http4s`, `mauth-authenticator-scala`, `mauth-test-utils` % "test") .settings( basicSettings, moduleName := "mauth-authenticator-http4s", @@ -182,8 +182,10 @@ lazy val `mauth-authenticator-http4s` = (project in file("modules/mauth-authenti testFrameworks += new TestFramework("munit.Framework"), libraryDependencies ++= Dependencies.provided(http4sDsl) ++ + Dependencies.provided(http4sClient) ++ Dependencies.compile(enumeratum) ++ Dependencies.compile(log4cats) ++ + Dependencies.compile(jacksonDataBind, scalaCache) ++ Dependencies.test(munitCatsEffect) ) diff --git a/modules/mauth-authenticator-akka-http/src/main/scala/com/mdsol/mauth/akka/http/MAuthDirectives.scala b/modules/mauth-authenticator-akka-http/src/main/scala/com/mdsol/mauth/akka/http/MAuthDirectives.scala index c8e5fad8..6a77e4bd 100644 --- a/modules/mauth-authenticator-akka-http/src/main/scala/com/mdsol/mauth/akka/http/MAuthDirectives.scala +++ b/modules/mauth-authenticator-akka-http/src/main/scala/com/mdsol/mauth/akka/http/MAuthDirectives.scala @@ -15,6 +15,7 @@ import com.mdsol.mauth.http.{`X-MWS-Authentication`, `X-MWS-Time`, HttpVerbOps} import com.mdsol.mauth.scaladsl.Authenticator import com.typesafe.scalalogging.StrictLogging +import scala.concurrent.Future import scala.concurrent.duration.{Duration, FiniteDuration} import scala.util.control.NonFatal import scala.util.{Success, Try} @@ -42,45 +43,43 @@ trait MAuthDirectives extends StrictLogging { * @param requestValidationTimeout request validation timeout duration, defaults to 10 seconds * @return Directive to authenticate the request */ - def authenticate(implicit authenticator: Authenticator, timeout: FiniteDuration, requestValidationTimeout: Duration): Directive0 = { - extractExecutionContext.flatMap { implicit ec => - extractLatestAuthenticationHeaders(authenticator.isV2OnlyAuthenticate).flatMap { mauthHeaderValues: MauthHeaderValues => - toStrictEntity(timeout) & - extractRequest.flatMap { req => - val isAuthed: Directive[Unit] = req.entity match { - case entity: HttpEntity.Strict => - val mAuthRequest: MAuthRequest = new MAuthRequest( - mauthHeaderValues.authenticator, - entity.data.toArray[Byte], - HttpVerbOps.httpVerb(req.method), - mauthHeaderValues.time.toString, - req.uri.path.toString, - getQueryString(req) - ) - if (!authenticator.isV2OnlyAuthenticate) { - // store V1 headers for fallback to V1 authentication if V2 failed - val xmwsAuthenticationHeader = extractRequestHeader(req, MAuthRequest.X_MWS_AUTHENTICATION_HEADER_NAME) - val xmwsTimeHeader = extractRequestHeader(req, MAuthRequest.X_MWS_TIME_HEADER_NAME) - if (xmwsAuthenticationHeader.nonEmpty && xmwsTimeHeader.nonEmpty) { - mAuthRequest.setXmwsSignature(xmwsAuthenticationHeader) - mAuthRequest.setXmwsTime(xmwsTimeHeader) - } + def authenticate(implicit authenticator: Authenticator[Future], timeout: FiniteDuration, requestValidationTimeout: Duration): Directive0 = { + extractLatestAuthenticationHeaders(authenticator.isV2OnlyAuthenticate).flatMap { (mauthHeaderValues: MauthHeaderValues) => + toStrictEntity(timeout) & + extractRequest.flatMap { req => + val isAuthed: Directive[Unit] = req.entity match { + case entity: HttpEntity.Strict => + val mAuthRequest: MAuthRequest = new MAuthRequest( + mauthHeaderValues.authenticator, + entity.data.toArray[Byte], + HttpVerbOps.httpVerb(req.method), + mauthHeaderValues.time.toString, + req.uri.path.toString, + getQueryString(req) + ) + if (!authenticator.isV2OnlyAuthenticate) { + // store V1 headers for fallback to V1 authentication if V2 failed + val xmwsAuthenticationHeader = extractRequestHeader(req, MAuthRequest.X_MWS_AUTHENTICATION_HEADER_NAME) + val xmwsTimeHeader = extractRequestHeader(req, MAuthRequest.X_MWS_TIME_HEADER_NAME) + if (xmwsAuthenticationHeader.nonEmpty && xmwsTimeHeader.nonEmpty) { + mAuthRequest.setXmwsSignature(xmwsAuthenticationHeader) + mAuthRequest.setXmwsTime(xmwsTimeHeader) } - onComplete( - authenticator.authenticate( - mAuthRequest - )(ec, requestValidationTimeout) - ).flatMap[Unit] { - case Success(true) => pass - case _ => reject(MdsolAuthFailedRejection) - } - case _ => - logger.error(s"MAUTH: Non-Strict Entity in Request") - reject(MdsolAuthFailedRejection) - } - isAuthed + } + onComplete( + authenticator.authenticate( + mAuthRequest + )(requestValidationTimeout) + ).flatMap[Unit] { + case Success(true) => pass + case _ => reject(MdsolAuthFailedRejection) + } + case _ => + logger.error(s"MAUTH: Non-Strict Entity in Request") + reject(MdsolAuthFailedRejection) } - } + isAuthed + } } } @@ -168,7 +167,7 @@ trait MAuthDirectives extends StrictLogging { * the request is rejected with a MdsolAuthMissingHeaderRejection if the expected header is not present */ def extractLatestAuthenticationHeaders(v2OnlyAuthenticate: Boolean): Directive1[MauthHeaderValues] = { - extractRequest.flatMap { request: HttpRequest => + extractRequest.flatMap { (request: HttpRequest) => // Try to extract and verify V2 headers val authenticationHeaderStr = extractRequestHeader(request, MAuthRequest.MCC_AUTHENTICATION_HEADER_NAME) if (authenticationHeaderStr.nonEmpty) { diff --git a/modules/mauth-authenticator-akka-http/src/main/scala/com/mdsol/mauth/akka/http/MauthPublicKeyProvider.scala b/modules/mauth-authenticator-akka-http/src/main/scala/com/mdsol/mauth/akka/http/MauthPublicKeyProvider.scala index 53cd0cb8..3bf7f0e6 100644 --- a/modules/mauth-authenticator-akka-http/src/main/scala/com/mdsol/mauth/akka/http/MauthPublicKeyProvider.scala +++ b/modules/mauth-authenticator-akka-http/src/main/scala/com/mdsol/mauth/akka/http/MauthPublicKeyProvider.scala @@ -30,7 +30,7 @@ class MauthPublicKeyProvider(configuration: AuthenticatorConfiguration, signer: ec: ExecutionContext, system: ActorSystem, materializer: Materializer -) extends ClientPublicKeyProvider +) extends ClientPublicKeyProvider[Future] with StrictLogging { private val cCache = Caffeine.newBuilder().build[String, Entry[Option[PublicKey]]]() @@ -70,12 +70,12 @@ class MauthPublicKeyProvider(configuration: AuthenticatorConfiguration, signer: logger.error(s"Unexpected response returned by server -- status: ${response.status} response: $body") None } - }.handleError { error: Throwable => + }.handleError { (error: Throwable) => logger.error("Request to get MAuth public key couldn't be signed", error) None } } - .handleError { error: Throwable => + .handleError { (error: Throwable) => logger.error("Request to get MAuth public key couldn't be completed", error) None } diff --git a/modules/mauth-authenticator-akka-http/src/test/scala/com/mdsol/mauth/akka/http/MAuthDirectivesSpec.scala b/modules/mauth-authenticator-akka-http/src/test/scala/com/mdsol/mauth/akka/http/MAuthDirectivesSpec.scala index 0efa1307..53464713 100644 --- a/modules/mauth-authenticator-akka-http/src/test/scala/com/mdsol/mauth/akka/http/MAuthDirectivesSpec.scala +++ b/modules/mauth-authenticator-akka-http/src/test/scala/com/mdsol/mauth/akka/http/MAuthDirectivesSpec.scala @@ -44,7 +44,7 @@ class MAuthDirectivesSpec extends AnyWordSpec with Matchers with ScalatestRouteT private implicit val timeout: FiniteDuration = 30.seconds private implicit val requestValidationTimeout: Duration = 10.seconds - private val client = mock[ClientPublicKeyProvider] + private val client = mock[ClientPublicKeyProvider[Future]] private val mockEpochTimeProvider: EpochTimeProvider = mock[EpochTimeProvider] private val authenticator: RequestAuthenticator = new RequestAuthenticator(client, mockEpochTimeProvider) private val authenticatorV2: RequestAuthenticator = new RequestAuthenticator(client, mockEpochTimeProvider, v2OnlyAuthenticate = true) diff --git a/modules/mauth-authenticator-http4s/src/main/scala/com/mdsol/mauth/http4s/MAuthMiddleware.scala b/modules/mauth-authenticator-http4s/src/main/scala/com/mdsol/mauth/http4s/MAuthMiddleware.scala index dfa54d71..c29328d7 100644 --- a/modules/mauth-authenticator-http4s/src/main/scala/com/mdsol/mauth/http4s/MAuthMiddleware.scala +++ b/modules/mauth-authenticator-http4s/src/main/scala/com/mdsol/mauth/http4s/MAuthMiddleware.scala @@ -15,7 +15,6 @@ import enumeratum._ import org.typelevel.ci._ import org.typelevel.log4cats.slf4j.Slf4jLogger -import scala.concurrent.ExecutionContext import scala.concurrent.duration.Duration import scala.util.Try @@ -24,7 +23,7 @@ final case class MdsolAuthMissingHeaderRejection(headerName: String) extends Thr sealed trait HeaderVersion extends EnumEntry object HeaderVersion extends Enum[HeaderVersion] { - val values = findValues + val values: IndexedSeq[HeaderVersion] = findValues case object V1 extends HeaderVersion { val authHeaderName = ci"${MAuthRequest.X_MWS_AUTHENTICATION_HEADER_NAME}" @@ -38,10 +37,9 @@ object HeaderVersion extends Enum[HeaderVersion] { object MAuthMiddleware { import HeaderVersion._ - def apply[G[_]: Sync, F[_]](requestValidationTimeout: Duration, authenticator: Authenticator, fk: F ~> G)(http: Http[G, F])(implicit - ec: ExecutionContext, - F: Async[F] - ): Http[G, F] = + def apply[G[_]: Sync, F[_]](requestValidationTimeout: Duration, authenticator: Authenticator[F], fk: F ~> G)( + http: Http[G, F] + )(implicit F: Async[F]): Http[G, F] = Kleisli { request => val logger = Slf4jLogger.getLogger[G] @@ -96,7 +94,7 @@ object MAuthMiddleware { mAuthRequest } else mAuthRequest - F.fromFuture(F.delay(authenticator.authenticate(req)(ec, requestValidationTimeout))) + authenticator.authenticate(req)(requestValidationTimeout) } }).flatMap(b => if (b) http(request) @@ -106,11 +104,11 @@ object MAuthMiddleware { } } - def httpRoutes[F[_]: Async](requestValidationTimeout: Duration, authenticator: Authenticator)(httpRoutes: HttpRoutes[F])(implicit - ec: ExecutionContext + def httpRoutes[F[_]: Async](requestValidationTimeout: Duration, authenticator: Authenticator[F])( + httpRoutes: HttpRoutes[F] ): HttpRoutes[F] = apply(requestValidationTimeout, authenticator, OptionT.liftK[F])(httpRoutes) - def httpApp[F[_]: Async](requestValidationTimeout: Duration, authenticator: Authenticator)(httpRoutes: HttpApp[F])(implicit - ec: ExecutionContext + def httpApp[F[_]: Async](requestValidationTimeout: Duration, authenticator: Authenticator[F])( + httpRoutes: HttpApp[F] ): HttpApp[F] = apply(requestValidationTimeout, authenticator, FunctionK.id[F])(httpRoutes) } diff --git a/modules/mauth-authenticator-http4s/src/main/scala/com/mdsol/mauth/http4s/MauthPublicKeyProvider.scala b/modules/mauth-authenticator-http4s/src/main/scala/com/mdsol/mauth/http4s/MauthPublicKeyProvider.scala new file mode 100644 index 00000000..e0aba2a7 --- /dev/null +++ b/modules/mauth-authenticator-http4s/src/main/scala/com/mdsol/mauth/http4s/MauthPublicKeyProvider.scala @@ -0,0 +1,68 @@ +package com.mdsol.mauth.http4s + +import cats.effect.Async +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.benmanes.caffeine.cache.Caffeine +import com.mdsol.mauth.http4s.client.Implicits.NewSignedRequestOps +import com.mdsol.mauth.models.UnsignedRequest +import com.mdsol.mauth.scaladsl.utils.ClientPublicKeyProvider +import com.mdsol.mauth.util.MAuthKeysHelper +import com.mdsol.mauth.{AuthenticatorConfiguration, MAuthRequestSigner} +import com.typesafe.scalalogging.StrictLogging +import org.http4s.client.Client +import org.http4s.{Response, Status} +import scalacache.caffeine.CaffeineCache +import scalacache.memoization.memoizeF +import scalacache.{Cache, Entry} + +import java.net.URI +import java.security.PublicKey +import java.util.UUID +import scala.concurrent.duration._ +import scala.util.{Failure, Success, Try} +import cats.implicits._ + +class MauthPublicKeyProvider[F[_]](configuration: AuthenticatorConfiguration, signer: MAuthRequestSigner, val client: Client[F])(implicit F: Async[F]) + extends ClientPublicKeyProvider[F] + with StrictLogging { + + private val cCache = Caffeine.newBuilder().build[String, Entry[Option[PublicKey]]]() + implicit val caffeineCache: Cache[F, String, Option[PublicKey]] = CaffeineCache[F, String, Option[PublicKey]](underlying = cCache) + protected val mapper = new ObjectMapper + + /** Returns the associated public key for a given application UUID. + * + * @param appUUID , UUID of the application for which we want to retrieve its public key. + * @return { @link PublicKey} registered in MAuth for the application with given appUUID. + */ + override def getPublicKey(appUUID: UUID): F[Option[PublicKey]] = memoizeF(Some(configuration.getTimeToLive.seconds)) { + val uri = new URI(configuration.getBaseUrl + getRequestUrlPath(appUUID)) + val signedRequest = signer.signRequest(UnsignedRequest.noBody("GET", uri, headers = Map.empty)) + client.run(signedRequest.toHttp4sRequest[F]).use(retrievePublicKey) + } + + private def retrievePublicKey(mauthPublicKeyFetcher: Response[F]): F[Option[PublicKey]] = { + if (mauthPublicKeyFetcher.status == Status.Ok) { + mauthPublicKeyFetcher + .as[String] + .map(str => + Try( + MAuthKeysHelper.getPublicKeyFromString( + mapper.readTree(str).findValue("public_key_str").asText() + ) + ) match { + case Success(publicKey) => Some(publicKey) + case Failure(error) => + logger.error("Converting string to Public Key failed", error) + None + } + ) + } else { + logger.error(s"Unexpected response returned by server -- status: ${mauthPublicKeyFetcher.status} response: ${mauthPublicKeyFetcher.body}") + F.pure[Option[PublicKey]](None) + } + } + + protected def getRequestUrlPath(appUUID: UUID): String = + configuration.getRequestUrlPath + String.format(configuration.getSecurityTokensUrlPath, appUUID.toString) +} diff --git a/modules/mauth-authenticator-http4s/src/test/scala/com/mdsol/mauth/http4s/MAuthMiddlewareSuite.scala b/modules/mauth-authenticator-http4s/src/test/scala/com/mdsol/mauth/http4s/MAuthMiddlewareSuite.scala index c3c91be9..3641acfe 100644 --- a/modules/mauth-authenticator-http4s/src/test/scala/com/mdsol/mauth/http4s/MAuthMiddlewareSuite.scala +++ b/modules/mauth-authenticator-http4s/src/test/scala/com/mdsol/mauth/http4s/MAuthMiddlewareSuite.scala @@ -4,7 +4,7 @@ import cats.effect._ import cats.syntax.all._ import com.mdsol.mauth.MAuthRequest import com.mdsol.mauth.exception.MAuthValidationException -import com.mdsol.mauth.scaladsl.RequestAuthenticator +import com.mdsol.mauth.scaladsl.RequestAuthenticatorF import com.mdsol.mauth.scaladsl.utils.ClientPublicKeyProvider import com.mdsol.mauth.test.utils.TestFixtures import com.mdsol.mauth.util.{EpochTimeProvider, MAuthKeysHelper} @@ -17,7 +17,6 @@ import org.http4s.Method._ import java.security.{PublicKey, Security} import java.util.UUID -import scala.concurrent.Future import scala.concurrent.duration._ class MAuthMiddlewareSuite extends CatsEffectSuite { @@ -62,22 +61,23 @@ class MAuthMiddlewareSuite extends CatsEffectSuite { private val timeHeader: Long = 1509041057L private val publicKey = MAuthKeysHelper.getPublicKeyFromString(TestFixtures.PUBLIC_KEY_1) - private val client = new ClientPublicKeyProvider { - override def getPublicKey(appUUID: UUID): Future[Option[PublicKey]] = + private val client = new ClientPublicKeyProvider[IO] { + + override def getPublicKey(appUUID: UUID): IO[Option[PublicKey]] = if (appUUID == appUuid) { - Future(publicKey.some) - } else Future.failed(new Throwable("Wrong app UUID")) + IO.pure(publicKey.some) + } else IO.raiseError(new Throwable("Wrong app UUID")) } private val epochTimeProvider = new EpochTimeProvider { override def inSeconds(): Long = timeHeader } - private implicit val authenticator: RequestAuthenticator = new RequestAuthenticator(client, epochTimeProvider) + private implicit val authenticator: RequestAuthenticatorF[IO] = new RequestAuthenticatorF(client, epochTimeProvider) private val service = MAuthMiddleware.httpRoutes[IO](requestValidationTimeout, authenticator)(route).orNotFound - val authenticatorV2: RequestAuthenticator = new RequestAuthenticator(client, epochTimeProvider, v2OnlyAuthenticate = true) + val authenticatorV2: RequestAuthenticatorF[IO] = new RequestAuthenticatorF(client, epochTimeProvider, v2OnlyAuthenticate = true) val serviceV2 = MAuthMiddleware.httpRoutes[IO](requestValidationTimeout, authenticatorV2)(route).orNotFound @@ -138,14 +138,14 @@ class MAuthMiddlewareSuite extends CatsEffectSuite { } test("reject if public key cannot be found") { - val localClient = new ClientPublicKeyProvider { - override def getPublicKey(appUUID: UUID): Future[Option[PublicKey]] = + val localClient = new ClientPublicKeyProvider[IO] { + override def getPublicKey(appUUID: UUID): IO[Option[PublicKey]] = if (appUUID == appUuid) { - Future(none) - } else Future.failed(new Throwable("Wrong app UUID")) + IO.pure(none) + } else IO.raiseError(new Throwable("Wrong app UUID")) } - val localAuthenticator: RequestAuthenticator = new RequestAuthenticator(localClient, epochTimeProvider) + val localAuthenticator: RequestAuthenticatorF[IO] = new RequestAuthenticatorF(localClient, epochTimeProvider) val localService = MAuthMiddleware.httpRoutes[IO](requestValidationTimeout, localAuthenticator)(route).orNotFound diff --git a/modules/mauth-authenticator-http4s/src/test/scala/com/mdsol/mauth/http4s/MauthPublicKeyProviderSuite.scala b/modules/mauth-authenticator-http4s/src/test/scala/com/mdsol/mauth/http4s/MauthPublicKeyProviderSuite.scala new file mode 100644 index 00000000..9d198f0a --- /dev/null +++ b/modules/mauth-authenticator-http4s/src/test/scala/com/mdsol/mauth/http4s/MauthPublicKeyProviderSuite.scala @@ -0,0 +1,61 @@ +package com.mdsol.mauth.http4s + +import cats.data.Kleisli +import cats.effect.IO +import com.mdsol.mauth.test.utils.{FakeMAuthServer, PortFinder, TestFixtures} +import com.mdsol.mauth.{AuthenticatorConfiguration, MAuthRequestSigner} +import munit.CatsEffectSuite +import org.http4s.dsl.io._ +import org.http4s.implicits._ +import org.http4s._ +import org.http4s.client.Client + +class MauthPublicKeyProviderSuite extends CatsEffectSuite { + + private val MAUTH_PORT = PortFinder.findFreePort() + private val MAUTH_BASE_URL = s"http://localhost:$MAUTH_PORT" + private val MAUTH_URL_PATH = "/mauth/v1" + private val SECURITY_TOKENS_PATH = "/security_tokens/%s.json" + def executeRequest(uuid: String, response: IO[Response[IO]]): Kleisli[IO, Request[IO], Response[IO]] = + HttpRoutes + .of[IO] { + case GET -> _ / _ / _ / _ / "mauth" / "v1" / "security_tokens" / appId if appId == s"$uuid.json" => response + case GET -> Root / "mauth" / "v1" / "security_tokens" / appId if appId == s"${FakeMAuthServer.NON_EXISTING_CLIENT_APP_UUID}.json" => response + } + .orNotFound + private def getMAuthConfiguration = new AuthenticatorConfiguration(MAUTH_BASE_URL, MAUTH_URL_PATH, SECURITY_TOKENS_PATH) + + test("MauthPublicKeyProvider retrieve PublicKey from MAuth Server") { + val signer = new MAuthRequestSigner( + FakeMAuthServer.EXISTING_CLIENT_APP_UUID, + TestFixtures.PRIVATE_KEY_1 + ) + + new MauthPublicKeyProvider[IO]( + getMAuthConfiguration, + signer = signer, + client = Client.fromHttpApp(executeRequest(FakeMAuthServer.EXISTING_CLIENT_APP_UUID.toString, Ok(FakeMAuthServer.mockedMauthTokenResponse()))) + ).getPublicKey( + FakeMAuthServer.EXISTING_CLIENT_APP_UUID + ).map(_.nonEmpty) + .assertEquals(true) + } + + test("fail on invalid response from MAuth Server") { + val signer = new MAuthRequestSigner( + FakeMAuthServer.EXISTING_CLIENT_APP_UUID, + TestFixtures.PRIVATE_KEY_1 + ) + val mockedSigner = signer + + new MauthPublicKeyProvider[IO]( + getMAuthConfiguration, + signer = mockedSigner, + client = Client.fromHttpApp(executeRequest(FakeMAuthServer.NON_EXISTING_CLIENT_APP_UUID.toString, IO(Response[IO](status = Unauthorized)))) + ).getPublicKey( + FakeMAuthServer.NON_EXISTING_CLIENT_APP_UUID + ).map(_.nonEmpty) + .assertEquals(false) + } + +} diff --git a/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/Authenticator.scala b/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/Authenticator.scala index b5183db6..2bb088f3 100644 --- a/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/Authenticator.scala +++ b/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/Authenticator.scala @@ -1,11 +1,16 @@ package com.mdsol.mauth.scaladsl -import com.mdsol.mauth.MAuthRequest +import com.mdsol.mauth.exception.MAuthValidationException +import com.mdsol.mauth.{MAuthRequest, MAuthVersion} +import com.mdsol.mauth.util.{EpochTimeProvider, MAuthSignatureHelper} +import org.slf4j.Logger +import java.nio.charset.StandardCharsets +import java.security.PublicKey +import java.util import scala.concurrent.duration.Duration -import scala.concurrent.{ExecutionContext, Future} -trait Authenticator { +trait Authenticator[F[_]] { /** Performs the validation of an incoming HTTP request. * @@ -15,11 +20,85 @@ trait Authenticator { * @param mAuthRequest Data from the incoming HTTP request necessary to perform the validation. * @return True or false indicating if the request is valid or not with respect to mAuth. */ - def authenticate(mAuthRequest: MAuthRequest)(implicit ex: ExecutionContext, requestValidationTimeout: Duration): Future[Boolean] + def authenticate(mAuthRequest: MAuthRequest)(implicit requestValidationTimeout: Duration): F[Boolean] /** check if mauth v2 only authenticate is enabled or not * @return True or false identifying if mauth v2 only is enabled or not. */ def isV2OnlyAuthenticate: Boolean + def epochTimeProvider: EpochTimeProvider + + def logger: Logger + + // Check epoch time is not older than specified interval. + protected def validateTime(requestTime: Long)(requestValidationTimeout: Duration): Boolean = + (epochTimeProvider.inSeconds - requestTime) < requestValidationTimeout.toSeconds + + // Check V2 header if only V2 is required + protected def validateMauthVersion(mAuthRequest: MAuthRequest, v2OnlyAuthenticate: Boolean): Boolean = + !v2OnlyAuthenticate || mAuthRequest.getMauthVersion == MAuthVersion.MWSV2 + + // check signature for V1 + protected def validateSignatureV1(mAuthRequest: MAuthRequest, clientPublicKey: PublicKey): Boolean = { + logAuthenticationRequest(mAuthRequest) + val decryptedSignature = MAuthSignatureHelper.decryptSignature(clientPublicKey, mAuthRequest.getRequestSignature) + // Recreate the plain text signature, based on the incoming request parameters, and hash it. + try { + val messageDigest_bytes = MAuthSignatureHelper.generateDigestedMessageV1(mAuthRequest).getBytes(StandardCharsets.UTF_8) + + // Compare the decrypted signature and the recreated signature hashes. + // If both match, the request was signed by the requesting application and is valid. + util.Arrays.equals(messageDigest_bytes, decryptedSignature) + } catch { + case ex: Exception => + val message = "MAuth request validation failed for V1." + logger.error(message, ex) + throw new MAuthValidationException(message, ex) + } + } + + // check signature for V2 + protected def validateSignatureV2(mAuthRequest: MAuthRequest, clientPublicKey: PublicKey): Boolean = { + logAuthenticationRequest(mAuthRequest) + // Recreate the plain text signature, based on the incoming request parameters, and hash it. + val unencryptedRequestString = MAuthSignatureHelper.generateStringToSignV2(mAuthRequest) + + // Compare the decrypted signature and the recreated signature hashes. + try MAuthSignatureHelper.verifyRSA(unencryptedRequestString, mAuthRequest.getRequestSignature, clientPublicKey) + catch { + case ex: Exception => + val message = "MAuth request validation failed for V2." + logger.error(message, ex) + throw new MAuthValidationException(message, ex) + } + + } + + protected def fallbackValidateSignatureV1(mAuthRequest: MAuthRequest, clientPublicKey: PublicKey): Boolean = { + var isValidated = false + if (mAuthRequest.getMessagePayload == null) { + logger.warn("V1 authentication fallback is not available because the full request body is not available in memory.") + } else if (mAuthRequest.getXmwsSignature != null && mAuthRequest.getXmwsTime != null) { + val mAuthRequestV1 = new MAuthRequest( + mAuthRequest.getXmwsSignature, + mAuthRequest.getMessagePayload, + mAuthRequest.getHttpMethod, + mAuthRequest.getXmwsTime, + mAuthRequest.getResourcePath, + mAuthRequest.getQueryParameters + ) + isValidated = validateSignatureV1(mAuthRequestV1, clientPublicKey) + if (isValidated) { + logger.warn("Completed successful authentication attempt after fallback to V1") + } + } + isValidated + } + + private def logAuthenticationRequest(mAuthRequest: MAuthRequest): Unit = { + val msgFormat = "Mauth-client attempting to authenticate request from app with mauth app uuid %s using version %s." + logger.info(String.format(msgFormat, mAuthRequest.getAppUUID, mAuthRequest.getMauthVersion.getValue)) + } + } diff --git a/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/RequestAuthenticator.scala b/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/RequestAuthenticator.scala index 70c73042..057a7156 100644 --- a/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/RequestAuthenticator.scala +++ b/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/RequestAuthenticator.scala @@ -1,26 +1,36 @@ package com.mdsol.mauth.scaladsl -import java.nio.charset.StandardCharsets -import java.security.PublicKey -import java.util - import com.mdsol.mauth.MAuthRequest import com.mdsol.mauth.MAuthVersion import com.mdsol.mauth.exception.MAuthValidationException import com.mdsol.mauth.scaladsl.utils.ClientPublicKeyProvider -import com.mdsol.mauth.util.{EpochTimeProvider, MAuthSignatureHelper} -import org.slf4j.LoggerFactory +import com.mdsol.mauth.util.EpochTimeProvider +import org.slf4j.{Logger, LoggerFactory} -import scala.concurrent.duration.Duration +import scala.concurrent.duration._ import scala.concurrent.{ExecutionContext, Future, Promise} -class RequestAuthenticator(publicKeyProvider: ClientPublicKeyProvider, epochTimeProvider: EpochTimeProvider, v2OnlyAuthenticate: Boolean) - extends Authenticator { +class RequestAuthenticator( + val publicKeyProvider: ClientPublicKeyProvider[Future], + override val epochTimeProvider: EpochTimeProvider, + v2OnlyAuthenticate: Boolean +)(implicit val executionContext: ExecutionContext) + extends Authenticator[Future] { + + val logger: Logger = LoggerFactory.getLogger(classOf[RequestAuthenticator]) + + def message(requestValidationTimeout: Duration) = s"MAuth request validation failed because of timeout $requestValidationTimeout" - private val logger = LoggerFactory.getLogger(classOf[RequestAuthenticator]) + val message = "The service requires mAuth v2 authentication headers." - def this(publicKeyProvider: ClientPublicKeyProvider, epochTimeProvider: EpochTimeProvider) = - this(publicKeyProvider, epochTimeProvider, false) + def this(publicKeyProvider: ClientPublicKeyProvider[Future], epochTimeProvider: EpochTimeProvider)(implicit executionContext: ExecutionContext) = + this(publicKeyProvider, epochTimeProvider, false)(executionContext) + + /** check if mauth v2 only authenticate is enabled or not + * + * @return True or false identifying if v2 only authenticate is enabled or not. + */ + override val isV2OnlyAuthenticate: Boolean = v2OnlyAuthenticate /** Performs the validation of an incoming HTTP request. * @@ -30,116 +40,42 @@ class RequestAuthenticator(publicKeyProvider: ClientPublicKeyProvider, epochTime * @param mAuthRequest Data from the incoming HTTP request necessary to perform the validation. * @return True or false indicating if the request is valid or not with respect to mAuth. */ - override def authenticate(mAuthRequest: MAuthRequest)(implicit ex: ExecutionContext, requestValidationTimeout: Duration): Future[Boolean] = { - + override def authenticate(mAuthRequest: MAuthRequest)(implicit requestValidationTimeout: Duration): Future[Boolean] = { val promise = Promise[Boolean]() if (!validateTime(mAuthRequest.getRequestTime)(requestValidationTimeout)) { - val message = s"MAuth request validation failed because of timeout $requestValidationTimeout" - logger.error(message) - promise.failure(new MAuthValidationException(message)) + logger.error(message(requestValidationTimeout)) + promise.failure(new MAuthValidationException(message(requestValidationTimeout))) } else if (!validateMauthVersion(mAuthRequest, v2OnlyAuthenticate)) { val message = "The service requires mAuth v2 authentication headers." logger.error(message) promise.failure(new MAuthValidationException(message)) } else { promise.completeWith( - publicKeyProvider.getPublicKey(mAuthRequest.getAppUUID).map { - case None => - logger.error("Public Key couldn't be retrieved") - false - case Some(clientPublicKey) => - // Decrypt the signature with public key from requesting application. - mAuthRequest.getMauthVersion match { - case MAuthVersion.MWS => - validateSignatureV1(mAuthRequest, clientPublicKey) - case MAuthVersion.MWSV2 => - val v2IsValidated = validateSignatureV2(mAuthRequest, clientPublicKey) - if (v2OnlyAuthenticate) - v2IsValidated - else if (v2IsValidated) - v2IsValidated - else - fallbackValidateSignatureV1(mAuthRequest, clientPublicKey) - } - } + getPublicKey(mAuthRequest) ) } promise.future } - /** check if mauth v2 only authenticate is enabled or not - * @return True or false identifying if v2 only authenticate is enabled or not. - */ - override val isV2OnlyAuthenticate: Boolean = v2OnlyAuthenticate - - // Check epoch time is not older than specified interval. - protected def validateTime(requestTime: Long)(requestValidationTimeout: Duration): Boolean = - (epochTimeProvider.inSeconds - requestTime) < requestValidationTimeout.toSeconds - - // Check V2 header if only V2 is required - protected def validateMauthVersion(mAuthRequest: MAuthRequest, v2OnlyAuthenticate: Boolean): Boolean = - !v2OnlyAuthenticate || mAuthRequest.getMauthVersion == MAuthVersion.MWSV2 - - // check signature for V1 - private def validateSignatureV1(mAuthRequest: MAuthRequest, clientPublicKey: PublicKey): Boolean = { - logAuthenticationRequest(mAuthRequest) - val decryptedSignature = MAuthSignatureHelper.decryptSignature(clientPublicKey, mAuthRequest.getRequestSignature) - // Recreate the plain text signature, based on the incoming request parameters, and hash it. - try { - val messageDigest_bytes = MAuthSignatureHelper.generateDigestedMessageV1(mAuthRequest).getBytes(StandardCharsets.UTF_8) - - // Compare the decrypted signature and the recreated signature hashes. - // If both match, the request was signed by the requesting application and is valid. - util.Arrays.equals(messageDigest_bytes, decryptedSignature) - } catch { - case ex: Exception => - val message = "MAuth request validation failed for V1." - logger.error(message, ex) - throw new MAuthValidationException(message, ex) - } - } - - // check signature for V2 - private def validateSignatureV2(mAuthRequest: MAuthRequest, clientPublicKey: PublicKey): Boolean = { - logAuthenticationRequest(mAuthRequest) - // Recreate the plain text signature, based on the incoming request parameters, and hash it. - val unencryptedRequestString = MAuthSignatureHelper.generateStringToSignV2(mAuthRequest) - - // Compare the decrypted signature and the recreated signature hashes. - try MAuthSignatureHelper.verifyRSA(unencryptedRequestString, mAuthRequest.getRequestSignature, clientPublicKey) - catch { - case ex: Exception => - val message = "MAuth request validation failed for V2." - logger.error(message, ex) - throw new MAuthValidationException(message, ex) - } - - } - - private def fallbackValidateSignatureV1(mAuthRequest: MAuthRequest, clientPublicKey: PublicKey) = { - var isValidated = false - if (mAuthRequest.getMessagePayload == null) { - logger.warn("V1 authentication fallback is not available because the full request body is not available in memory.") - } else if (mAuthRequest.getXmwsSignature != null && mAuthRequest.getXmwsTime != null) { - val mAuthRequestV1 = new MAuthRequest( - mAuthRequest.getXmwsSignature, - mAuthRequest.getMessagePayload, - mAuthRequest.getHttpMethod, - mAuthRequest.getXmwsTime, - mAuthRequest.getResourcePath, - mAuthRequest.getQueryParameters - ) - isValidated = validateSignatureV1(mAuthRequestV1, clientPublicKey) - if (isValidated) { - logger.warn("Completed successful authentication attempt after fallback to V1") - } + private def getPublicKey(mAuthRequest: MAuthRequest): Future[Boolean] = { + publicKeyProvider.getPublicKey(mAuthRequest.getAppUUID).map { + case None => + logger.error("Public Key couldn't be retrieved") + false + case Some(clientPublicKey) => + // Decrypt the signature with public key from requesting application. + mAuthRequest.getMauthVersion match { + case MAuthVersion.MWS => + validateSignatureV1(mAuthRequest, clientPublicKey) + case MAuthVersion.MWSV2 => + val v2IsValidated = validateSignatureV2(mAuthRequest, clientPublicKey) + if (v2OnlyAuthenticate) + v2IsValidated + else if (v2IsValidated) + v2IsValidated + else + fallbackValidateSignatureV1(mAuthRequest, clientPublicKey) + } } - isValidated - } - - private def logAuthenticationRequest(mAuthRequest: MAuthRequest): Unit = { - val msgFormat = "Mauth-client attempting to authenticate request from app with mauth app uuid %s using version %s." - logger.info(String.format(msgFormat, mAuthRequest.getAppUUID, mAuthRequest.getMauthVersion.getValue)) } - } diff --git a/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/RequestAuthenticatorF.scala b/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/RequestAuthenticatorF.scala new file mode 100644 index 00000000..516c149c --- /dev/null +++ b/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/RequestAuthenticatorF.scala @@ -0,0 +1,76 @@ +package com.mdsol.mauth.scaladsl + +import cats.effect.Async +import com.mdsol.mauth.MAuthRequest +import com.mdsol.mauth.MAuthVersion +import com.mdsol.mauth.exception.MAuthValidationException +import com.mdsol.mauth.scaladsl.utils.ClientPublicKeyProvider +import com.mdsol.mauth.util.EpochTimeProvider +import org.slf4j.{Logger, LoggerFactory} + +import scala.concurrent.duration._ +import cats.implicits._ + +class RequestAuthenticatorF[F[_]]( + val publicKeyProvider: ClientPublicKeyProvider[F], + override val epochTimeProvider: EpochTimeProvider, + v2OnlyAuthenticate: Boolean +)(implicit F: Async[F]) + extends Authenticator[F] { + + val logger: Logger = LoggerFactory.getLogger(classOf[RequestAuthenticator]) + def message(requestValidationTimeout: Duration) = s"MAuth request validation failed because of timeout $requestValidationTimeout" + val message = "The service requires mAuth v2 authentication headers." + def this(publicKeyProvider: ClientPublicKeyProvider[F], epochTimeProvider: EpochTimeProvider)(implicit F: Async[F]) = + this(publicKeyProvider, epochTimeProvider, false)(F) + + /** check if mauth v2 only authenticate is enabled or not + * + * @return True or false identifying if v2 only authenticate is enabled or not. + */ + override val isV2OnlyAuthenticate: Boolean = v2OnlyAuthenticate + + /** Performs the validation of an incoming HTTP request. + * + * The validation process consists of recreating the mAuth hashed signature from the request data + * and comparing it to the decrypted hash signature from the mAuth header. + * + * @param mAuthRequest Data from the incoming HTTP request necessary to perform the validation. + * @return True or false indicating if the request is valid or not with respect to mAuth. + */ + override def authenticate(mAuthRequest: MAuthRequest)(implicit requestValidationTimeout: Duration): F[Boolean] = { + if (!validateTime(mAuthRequest.getRequestTime)(requestValidationTimeout)) { + logger.error(message(requestValidationTimeout)) + F.raiseError(new MAuthValidationException(message(requestValidationTimeout))) + } else if (!validateMauthVersion(mAuthRequest, v2OnlyAuthenticate)) { + + logger.error(message) + F.raiseError(new MAuthValidationException(message)) + } else { + getPublicKeyF(mAuthRequest) + } + } + + def getPublicKeyF(mAuthRequest: MAuthRequest): F[Boolean] = { + publicKeyProvider.getPublicKey(mAuthRequest.getAppUUID).map { + case None => + logger.error("Public Key couldn't be retrieved") + false + case Some(clientPublicKey) => + // Decrypt the signature with public key from requesting application. + mAuthRequest.getMauthVersion match { + case MAuthVersion.MWS => + validateSignatureV1(mAuthRequest, clientPublicKey) + case MAuthVersion.MWSV2 => + val v2IsValidated = validateSignatureV2(mAuthRequest, clientPublicKey) + if (isV2OnlyAuthenticate) + v2IsValidated + else if (v2IsValidated) + v2IsValidated + else + fallbackValidateSignatureV1(mAuthRequest, clientPublicKey) + } + } + } + +} diff --git a/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/utils/ClientPublicKeyProvider.scala b/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/utils/ClientPublicKeyProvider.scala index 87258849..12faa918 100644 --- a/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/utils/ClientPublicKeyProvider.scala +++ b/modules/mauth-authenticator-scala/src/main/scala/com/mdsol/mauth/scaladsl/utils/ClientPublicKeyProvider.scala @@ -3,14 +3,12 @@ package com.mdsol.mauth.scaladsl.utils import java.security.PublicKey import java.util.UUID -import scala.concurrent.Future - -trait ClientPublicKeyProvider { +trait ClientPublicKeyProvider[F[_]] { /** Returns the associated public key for a given application UUID. * * @param appUUID , UUID of the application for which we want to retrieve its public key. * @return Future of { @link PublicKey} registered in MAuth for the application with given appUUID. */ - def getPublicKey(appUUID: UUID): Future[Option[PublicKey]] + def getPublicKey(appUUID: UUID): F[Option[PublicKey]] } diff --git a/modules/mauth-authenticator-scala/src/test/scala/com/mdsol/mauth/scaladsl/utils/RequestAuthenticatorFSpec.scala b/modules/mauth-authenticator-scala/src/test/scala/com/mdsol/mauth/scaladsl/utils/RequestAuthenticatorFSpec.scala new file mode 100644 index 00000000..ca62e91f --- /dev/null +++ b/modules/mauth-authenticator-scala/src/test/scala/com/mdsol/mauth/scaladsl/utils/RequestAuthenticatorFSpec.scala @@ -0,0 +1,171 @@ +package com.mdsol.mauth.scaladsl.utils + +import cats.effect.IO +import com.mdsol.mauth.RequestAuthenticatorBaseSpec +import com.mdsol.mauth.exception.MAuthValidationException +import com.mdsol.mauth.scaladsl.RequestAuthenticatorF +import com.mdsol.mauth.test.utils.FakeMAuthServer.EXISTING_CLIENT_APP_UUID +import com.mdsol.mauth.util.MAuthKeysHelper +import org.scalamock.scalatest.MockFactory +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.util.UUID +import scala.concurrent.duration._ + +class RequestAuthenticatorFSpec extends AnyFlatSpec with RequestAuthenticatorBaseSpec with Matchers with ScalaFutures with MockFactory { + + import cats.effect.unsafe.implicits.global + + private implicit val requestValidationTimeout: Duration = 10.seconds + + behavior of "RequestAuthenticatorF Scala" + + it should "authenticate a valid request" in clientContext { client => + //noinspection ConvertibleToMethodValue + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_X_MWS_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider) + + whenReady(authenticator.authenticate(getSimpleRequest).unsafeToFuture())(validationResult => validationResult shouldBe true) + } + + it should "authenticate a request with unicode chars in body" in clientContext { client => + //noinspection ConvertibleToMethodValue + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_UNICODE_X_MWS_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider) + + whenReady(authenticator.authenticate(getRequestWithUnicodeCharactersInBody).unsafeToFuture())(validationResult => validationResult shouldBe true) + } + + it should "authenticate a request without any body" in clientContext { client => + //noinspection ConvertibleToMethodValue + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_NO_BODY_X_MWS_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider) + + whenReady(authenticator.authenticate(getRequestWithoutMessageBody).unsafeToFuture())(validationResult => validationResult shouldBe true) + } + + it should "not authenticate an invalid request" in clientContext { client => + //noinspection ConvertibleToMethodValue + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_X_MWS_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider) + val invalidRequest = getSimpleRequestWithWrongSignature + + whenReady(authenticator.authenticate(invalidRequest).unsafeToFuture())(validationResult => validationResult shouldBe false) + } + + it should "not authenticate a request after timeout period passed" in { + //noinspection ConvertibleToMethodValue + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_UNICODE_X_MWS_TIME_HEADER_VALUE.toLong + 600) + val authenticator = new RequestAuthenticatorF(mock[ClientPublicKeyProvider[IO]], mockEpochTimeProvider) + + whenReady(authenticator.authenticate(getRequestWithUnicodeCharactersInBody).unsafeToFuture().failed) { + case e: MAuthValidationException => e.getMessage shouldBe "MAuth request validation failed because of timeout 10 seconds" + case _ => fail("should not be here") + } + } + + it should "authenticate a valid request with V2 headers" in clientContext { client => + //noinspection ConvertibleToMethodValue + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_X_MWS_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider) + + whenReady(authenticator.authenticate(getSimpleRequestV2).unsafeToFuture())(validationResult => validationResult shouldBe true) + } + + it should "authenticate a valid request with V2 headers only if V2 only enabled" in clientContext { client => + //noinspection ConvertibleToMethodValue + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_X_MWS_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider, true) + + val result = authenticator.authenticate(getSimpleRequestV2).unsafeToFuture().futureValue + result shouldBe true + } + + it should "authenticate a valid request with the both V1 and V2 headers provided" in clientContext { client => + //noinspection ConvertibleToMethodValue + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_X_MWS_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider) + + whenReady(authenticator.authenticate(getRequestWithAllHeaders).unsafeToFuture())(validationResult => validationResult shouldBe true) + } + + it should "reject a request with V1 headers when V2 only is enabled" in { + //noinspection ConvertibleToMethodValue + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_X_MWS_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(mock[ClientPublicKeyProvider[IO]], mockEpochTimeProvider, true) + + whenReady(authenticator.authenticate(getSimpleRequest).unsafeToFuture().failed) { + case e: MAuthValidationException => e.getMessage shouldBe "The service requires mAuth v2 authentication headers." + case _ => fail("should not be here") + } + } + + it should "authenticate a valid request with binary payload" in { + val client: ClientPublicKeyProvider[IO] = mock[ClientPublicKeyProvider[IO]] + (client.getPublicKey _).expects(UUID.fromString(CLIENT_REQUEST_BINARY_APP_UUID)).returns(IO(Some(MAuthKeysHelper.getPublicKeyFromString(PUBLIC_KEY2)))) + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_REQUEST_BINARY_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider) + + whenReady(authenticator.authenticate(getRequestWithBinaryBodyV1).unsafeToFuture())(validationResult => validationResult shouldBe true) + } + + it should "authenticate a valid request with binary payload for V2" in { + val client: ClientPublicKeyProvider[IO] = mock[ClientPublicKeyProvider[IO]] + (client.getPublicKey _).expects(UUID.fromString(CLIENT_REQUEST_BINARY_APP_UUID)).returns(IO(Some(MAuthKeysHelper.getPublicKeyFromString(PUBLIC_KEY2)))) + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_REQUEST_BINARY_TIME_HEADER_VALUE.toLong + 5) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider) + + whenReady(authenticator.authenticate(getRequestWithBinaryBodyV2).unsafeToFuture())(validationResult => validationResult shouldBe true) + } + + it should "validate the request with the validated V1 headers and wrong V2 signature" in clientContext { client => + //noinspection ConvertibleToMethodValue + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_X_MWS_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider) + + whenReady(authenticator.authenticate(getRequestWithWrongV2Signature).unsafeToFuture())(validationResult => validationResult shouldBe true) + } + + it should "fail validating request with validated V1 headers and wrong V2 signature if V2 only is enabled" in clientContext { client => + //noinspection ConvertibleToMethodValue + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_X_MWS_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider, true) + + whenReady(authenticator.authenticate(getRequestWithWrongV2Signature).unsafeToFuture())(validationResult => validationResult shouldBe false) + } + + "When payload is inputstream" should "authenticate a valid request for V1" in { + val client: ClientPublicKeyProvider[IO] = mock[ClientPublicKeyProvider[IO]] + (client.getPublicKey _).expects(UUID.fromString(CLIENT_REQUEST_BINARY_APP_UUID)).returns(IO(Some(MAuthKeysHelper.getPublicKeyFromString(PUBLIC_KEY2)))) + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_REQUEST_BINARY_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider) + + whenReady(authenticator.authenticate(getRequestWithStreamBodyV1).unsafeToFuture())(validationResult => validationResult shouldBe true) + } + + it should "authenticate a valid request for V2" in { + val client: ClientPublicKeyProvider[IO] = mock[ClientPublicKeyProvider[IO]] + (client.getPublicKey _).expects(UUID.fromString(CLIENT_REQUEST_BINARY_APP_UUID)).returns(IO(Some(MAuthKeysHelper.getPublicKeyFromString(PUBLIC_KEY2)))) + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_REQUEST_BINARY_TIME_HEADER_VALUE.toLong + 5) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider) + + whenReady(authenticator.authenticate(getRequestWithStreamBodyV2).unsafeToFuture())(validationResult => validationResult shouldBe true) + } + + it should "fail validating the request with the validated V1 headers and wrong V2 signature" in clientContext { client => + //noinspection ConvertibleToMethodValue + (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_X_MWS_TIME_HEADER_VALUE.toLong + 3) + val authenticator = new RequestAuthenticatorF(client, mockEpochTimeProvider) + + whenReady(authenticator.authenticate(getRequestWithStreamBodyAndWrongV2Signature).unsafeToFuture())(validationResult => validationResult shouldBe false) + } + + private def clientContext(test: ClientPublicKeyProvider[IO] => Any): Unit = { + val client: ClientPublicKeyProvider[IO] = mock[ClientPublicKeyProvider[IO]] + (client.getPublicKey _).expects(EXISTING_CLIENT_APP_UUID).returns(IO(Some(MAuthKeysHelper.getPublicKeyFromString(PUBLIC_KEY)))) + test(client) + () + } +} diff --git a/modules/mauth-authenticator-scala/src/test/scala/com/mdsol/mauth/scaladsl/utils/RequestAuthenticatorSpec.scala b/modules/mauth-authenticator-scala/src/test/scala/com/mdsol/mauth/scaladsl/utils/RequestAuthenticatorSpec.scala index 5e229487..dc52ea20 100644 --- a/modules/mauth-authenticator-scala/src/test/scala/com/mdsol/mauth/scaladsl/utils/RequestAuthenticatorSpec.scala +++ b/modules/mauth-authenticator-scala/src/test/scala/com/mdsol/mauth/scaladsl/utils/RequestAuthenticatorSpec.scala @@ -58,7 +58,7 @@ class RequestAuthenticatorSpec extends AnyFlatSpec with RequestAuthenticatorBase it should "not authenticate a request after timeout period passed" in { //noinspection ConvertibleToMethodValue (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_UNICODE_X_MWS_TIME_HEADER_VALUE.toLong + 600) - val authenticator = new RequestAuthenticator(mock[ClientPublicKeyProvider], mockEpochTimeProvider) + val authenticator = new RequestAuthenticator(mock[ClientPublicKeyProvider[Future]], mockEpochTimeProvider) whenReady(authenticator.authenticate(getRequestWithUnicodeCharactersInBody).failed) { case e: MAuthValidationException => e.getMessage shouldBe "MAuth request validation failed because of timeout 10 seconds" @@ -94,7 +94,7 @@ class RequestAuthenticatorSpec extends AnyFlatSpec with RequestAuthenticatorBase it should "reject a request with V1 headers when V2 only is enabled" in { //noinspection ConvertibleToMethodValue (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_X_MWS_TIME_HEADER_VALUE.toLong + 3) - val authenticator = new RequestAuthenticator(mock[ClientPublicKeyProvider], mockEpochTimeProvider, true) + val authenticator = new RequestAuthenticator(mock[ClientPublicKeyProvider[Future]], mockEpochTimeProvider, true) whenReady(authenticator.authenticate(getSimpleRequest).failed) { case e: MAuthValidationException => e.getMessage shouldBe "The service requires mAuth v2 authentication headers." @@ -103,7 +103,7 @@ class RequestAuthenticatorSpec extends AnyFlatSpec with RequestAuthenticatorBase } it should "authenticate a valid request with binary payload" in { - val client: ClientPublicKeyProvider = mock[ClientPublicKeyProvider] + val client: ClientPublicKeyProvider[Future] = mock[ClientPublicKeyProvider[Future]] (client.getPublicKey _).expects(UUID.fromString(CLIENT_REQUEST_BINARY_APP_UUID)).returns(Future(Some(MAuthKeysHelper.getPublicKeyFromString(PUBLIC_KEY2)))) (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_REQUEST_BINARY_TIME_HEADER_VALUE.toLong + 3) val authenticator = new RequestAuthenticator(client, mockEpochTimeProvider) @@ -112,7 +112,7 @@ class RequestAuthenticatorSpec extends AnyFlatSpec with RequestAuthenticatorBase } it should "authenticate a valid request with binary payload for V2" in { - val client: ClientPublicKeyProvider = mock[ClientPublicKeyProvider] + val client: ClientPublicKeyProvider[Future] = mock[ClientPublicKeyProvider[Future]] (client.getPublicKey _).expects(UUID.fromString(CLIENT_REQUEST_BINARY_APP_UUID)).returns(Future(Some(MAuthKeysHelper.getPublicKeyFromString(PUBLIC_KEY2)))) (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_REQUEST_BINARY_TIME_HEADER_VALUE.toLong + 5) val authenticator = new RequestAuthenticator(client, mockEpochTimeProvider) @@ -137,7 +137,7 @@ class RequestAuthenticatorSpec extends AnyFlatSpec with RequestAuthenticatorBase } "When payload is inputstream" should "authenticate a valid request for V1" in { - val client: ClientPublicKeyProvider = mock[ClientPublicKeyProvider] + val client: ClientPublicKeyProvider[Future] = mock[ClientPublicKeyProvider[Future]] (client.getPublicKey _).expects(UUID.fromString(CLIENT_REQUEST_BINARY_APP_UUID)).returns(Future(Some(MAuthKeysHelper.getPublicKeyFromString(PUBLIC_KEY2)))) (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_REQUEST_BINARY_TIME_HEADER_VALUE.toLong + 3) val authenticator = new RequestAuthenticator(client, mockEpochTimeProvider) @@ -146,7 +146,7 @@ class RequestAuthenticatorSpec extends AnyFlatSpec with RequestAuthenticatorBase } it should "authenticate a valid request for V2" in { - val client: ClientPublicKeyProvider = mock[ClientPublicKeyProvider] + val client: ClientPublicKeyProvider[Future] = mock[ClientPublicKeyProvider[Future]] (client.getPublicKey _).expects(UUID.fromString(CLIENT_REQUEST_BINARY_APP_UUID)).returns(Future(Some(MAuthKeysHelper.getPublicKeyFromString(PUBLIC_KEY2)))) (mockEpochTimeProvider.inSeconds _: () => Long).expects().returns(CLIENT_REQUEST_BINARY_TIME_HEADER_VALUE.toLong + 5) val authenticator = new RequestAuthenticator(client, mockEpochTimeProvider) @@ -162,8 +162,8 @@ class RequestAuthenticatorSpec extends AnyFlatSpec with RequestAuthenticatorBase whenReady(authenticator.authenticate(getRequestWithStreamBodyAndWrongV2Signature))(validationResult => validationResult shouldBe false) } - private def clientContext(test: ClientPublicKeyProvider => Any): Unit = { - val client: ClientPublicKeyProvider = mock[ClientPublicKeyProvider] + private def clientContext(test: ClientPublicKeyProvider[Future] => Any): Unit = { + val client: ClientPublicKeyProvider[Future] = mock[ClientPublicKeyProvider[Future]] (client.getPublicKey _).expects(EXISTING_CLIENT_APP_UUID).returns(Future(Some(MAuthKeysHelper.getPublicKeyFromString(PUBLIC_KEY)))) test(client) () diff --git a/modules/mauth-signer-akka-http/src/example/scala/com/mdsol/mauth/MauthRequestSignerExample.scala b/modules/mauth-signer-akka-http/src/example/scala/com/mdsol/mauth/MauthRequestSignerExample.scala index b9248f1d..20b0eea2 100644 --- a/modules/mauth-signer-akka-http/src/example/scala/com/mdsol/mauth/MauthRequestSignerExample.scala +++ b/modules/mauth-signer-akka-http/src/example/scala/com/mdsol/mauth/MauthRequestSignerExample.scala @@ -8,7 +8,7 @@ import com.typesafe.config.ConfigFactory import scala.concurrent.duration._ import scala.concurrent.{Await, ExecutionContext} - +import com.mdsol.mauth.models.{UnsignedRequest => NewUnsignedRequest} /** * Example how to sign requests using Akka HttpClient * Set up the following environment variables: @@ -25,7 +25,7 @@ object MauthRequestSignerExample { val httpMethod = "GET" val uri = URI.create("https://api.mdsol.com/v1/countries") - val signedRequest = MAuthRequestSigner(configuration).signRequest(UnsignedRequest(httpMethod, uri, body = Array.empty, headers = Map.empty)) + val signedRequest = MAuthRequestSigner(configuration).signRequest(NewUnsignedRequest(httpMethod, uri, body = Array.empty, headers = Map.empty)) Await.result( HttpClient.call(signedRequest.toAkkaHttpRequest).map(response => println(s"response code: ${response._1.value}, response: ${response._3.toString}")), 10.seconds diff --git a/modules/mauth-signer-http4s/src/main/scala/com/mdsol/mauth/http4s/client/Implicits.scala b/modules/mauth-signer-http4s/src/main/scala/com/mdsol/mauth/http4s/client/Implicits.scala new file mode 100644 index 00000000..740298e7 --- /dev/null +++ b/modules/mauth-signer-http4s/src/main/scala/com/mdsol/mauth/http4s/client/Implicits.scala @@ -0,0 +1,46 @@ +package com.mdsol.mauth.http4s.client + +import com.mdsol.mauth.models.SignedRequest +import org.http4s.headers.`Content-Type` +import org.http4s.{headers, Header, Headers, Method, Request, Uri} +import org.typelevel.ci.CIString + +import scala.annotation.nowarn +import scala.collection.immutable + +object Implicits { + + implicit class NewSignedRequestOps(val signedRequest: SignedRequest) extends AnyVal { + + /** Create an akka-http request from a [[models.SignedRequest]] + */ + def toHttp4sRequest[F[_]]: Request[F] = { + val contentType: Option[`Content-Type`] = extractContentTypeFromHeaders(signedRequest.req.headers) + val headersWithoutContentType: Map[String, String] = removeContentTypeFromHeaders(signedRequest.req.headers) + + val allHeaders: immutable.Seq[Header.Raw] = (headersWithoutContentType ++ signedRequest.mauthHeaders).toList + .map { case (name, value) => + Header.Raw(CIString(name), value) + } + + Request[F]( + method = Method.fromString(signedRequest.req.httpMethod).getOrElse(Method.GET), + uri = Uri(path = Uri.Path.unsafeFromString(signedRequest.req.uri.toString)), + body = fs2.Stream.emits(signedRequest.req.body), + headers = Headers(allHeaders) + ).withContentTypeOption(contentType) + } + + private def extractContentTypeFromHeaders(requestHeaders: Map[String, String]): Option[`Content-Type`] = + requestHeaders + .get(headers.`Content-Type`.toString) + .flatMap(str => `Content-Type`.parse(str).toOption) + + @nowarn("msg=.*Unused import.*") // compat import only needed for 2.12 + private def removeContentTypeFromHeaders(requestHeaders: Map[String, String]): Map[String, String] = { + import scala.collection.compat._ + requestHeaders.view.filterKeys(_ != headers.`Content-Type`.toString).toMap + } + } + +} diff --git a/modules/mauth-test-utils/src/main/java/com/mdsol/mauth/test/utils/FakeMAuthServer.java b/modules/mauth-test-utils/src/main/java/com/mdsol/mauth/test/utils/FakeMAuthServer.java index 77084425..905d2ffe 100644 --- a/modules/mauth-test-utils/src/main/java/com/mdsol/mauth/test/utils/FakeMAuthServer.java +++ b/modules/mauth-test-utils/src/main/java/com/mdsol/mauth/test/utils/FakeMAuthServer.java @@ -47,7 +47,7 @@ public static void return401() { .willReturn(WireMock.aResponse().withStatus(401).withBody("Invalid headers"))); } - private static String mockedMauthTokenResponse() { + public static String mockedMauthTokenResponse() { String response = null; final ObjectMapper mapper = new ObjectMapper(); try { diff --git a/project/BuildSettings.scala b/project/BuildSettings.scala index 687d91f2..ea7278ef 100644 --- a/project/BuildSettings.scala +++ b/project/BuildSettings.scala @@ -12,13 +12,14 @@ object BuildSettings { val env: util.Map[String, String] = System.getenv() val scala212 = "2.12.17" val scala213 = "2.13.10" + val scala31 = "3.2.2" lazy val basicSettings = Seq( homepage := Some(new URL("https://github.com/mdsol/mauth-jvm-clients")), organization := "com.mdsol", organizationHomepage := Some(new URL("http://mdsol.com")), description := "MAuth clients", - scalaVersion := scala213, + scalaVersion := scala31, resolvers += Resolver.mavenLocal, resolvers += Resolver.sonatypeRepo("releases"), javacOptions ++= Seq("-encoding", "UTF-8"), @@ -67,9 +68,9 @@ object BuildSettings { ), sonatypeProjectHosting := Some(GitHubHosting("austek", "mauth-jvm-clients", "austek@mdsol.com")), publishTo := sonatypePublishToBundle.value, - releaseTagComment := s"Releasing ${(version in ThisBuild).value} [ci skip]", - releaseCommitMessage := s"Setting version to ${(version in ThisBuild).value} [ci skip]", - releaseNextCommitMessage := s"Setting version to ${(version in ThisBuild).value} [ci skip]", + releaseTagComment := s"Releasing ${(ThisBuild / version ).value} [ci skip]", + releaseCommitMessage := s"Setting version to ${( ThisBuild / version).value} [ci skip]", + releaseNextCommitMessage := s"Setting version to ${(ThisBuild / version).value} [ci skip]", releasePublishArtifactsAction := PgpKeys.publishSigned.value, releaseCrossBuild := false, // true if you cross-build the project for multiple Scala versions releaseProcess := releaseSteps, diff --git a/project/Dependencies.scala b/project/Dependencies.scala index f7f22b6b..6a65261c 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -8,12 +8,12 @@ object Dependencies extends DependencyUtils { val logback = "1.4.4" val sttp = "3.8.3" val http4s = "0.23.16" - val enumeratum = "1.7.0" + val enumeratum = "1.7.2" val log4cats = "2.5.0" } - val akkaHttp: ModuleID = "com.typesafe.akka" %% "akka-http" % Version.akkaHttp - val akkaStream: ModuleID = "com.typesafe.akka" %% "akka-stream" % Version.akka + val akkaHttp: ModuleID = ("com.typesafe.akka" %% "akka-http" % Version.akkaHttp).cross(CrossVersion.for3Use2_13) + val akkaStream: ModuleID = ("com.typesafe.akka" %% "akka-stream" % Version.akka).cross(CrossVersion.for3Use2_13) val apacheHttpClient: ModuleID = "org.apache.httpcomponents" % "httpclient" % "4.5.13" val bouncyCastlePkix: ModuleID = "org.bouncycastle" % "bcpkix-jdk15on" % "1.70" val commonsCodec: ModuleID = "commons-codec" % "commons-codec" % "1.15" @@ -27,8 +27,8 @@ object Dependencies extends DependencyUtils { val scalaCache: ModuleID = "com.github.cb372" %% "scalacache-caffeine" % "1.0.0-M6" val scalaLogging: ModuleID = "com.typesafe.scala-logging" %% "scala-logging" % "3.9.5" val catsEffect: ModuleID = "org.typelevel" %% "cats-effect" % "3.4.0" - val sttp: ModuleID = "com.softwaremill.sttp.client3" %% "core" % Version.sttp - val sttpAkkaHttpBackend: ModuleID = "com.softwaremill.sttp.client3" %% "akka-http-backend" % Version.sttp + val sttp: ModuleID = ("com.softwaremill.sttp.client3" %% "core" % Version.sttp).cross(CrossVersion.for3Use2_13) + val sttpAkkaHttpBackend: ModuleID = ("com.softwaremill.sttp.client3" %% "akka-http-backend" % Version.sttp).cross(CrossVersion.for3Use2_13) val scalaLibCompat: ModuleID = "org.scala-lang.modules" %% "scala-collection-compat" % "2.8.1" val caffeine: ModuleID = "com.github.ben-manes.caffeine" % "caffeine" % "3.1.1" val http4sDsl: ModuleID = "org.http4s" %% "http4s-dsl" % Version.http4s @@ -41,9 +41,9 @@ object Dependencies extends DependencyUtils { "com.typesafe.akka" %% "akka-http-testkit" % Version.akkaHttp, "com.typesafe.akka" %% "akka-testkit" % Version.akka, "com.typesafe.akka" %% "akka-stream-testkit" % Version.akka - ) + ).map(_.cross(CrossVersion.for3Use2_13)) val commonsIO: ModuleID = "commons-io" % "commons-io" % "2.11.0" - val scalaMock: ModuleID = "org.scalamock" %% "scalamock" % "5.2.0" + val scalaMock: ModuleID = ("org.scalamock" %% "scalamock" % "5.2.0").cross(CrossVersion.for3Use2_13) val scalaTest: ModuleID = "org.scalatest" %% "scalatest" % "3.2.14" val wiremock: ModuleID = "com.github.tomakehurst" % "wiremock" % "2.27.2" val munitCatsEffect: ModuleID = "org.typelevel" %% "munit-cats-effect-3" % "1.0.7" diff --git a/project/build.properties b/project/build.properties index 6ca52652..d240e537 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1,2 +1,2 @@ # suppress inspection "UnusedProperty" -sbt.version = 1.6.2 +sbt.version = 1.8.3