All Downloads are FREE. Search and download functionalities are using the official Maven repository.

plugins.hmac.scala Maven / Gradle / Ivy

package otoroshi.plugins.hmac

import akka.http.scaladsl.util.FastFuture
import akka.stream.Materializer
import otoroshi.env.Env
import otoroshi.next.plugins.api.{NgPluginCategory, NgPluginVisibility, NgStep}
import otoroshi.script._
import otoroshi.utils.crypto.Signatures
import otoroshi.utils.syntax.implicits.BetterSyntax
import play.api.Logger
import play.api.libs.json.{JsObject, Json}
import play.api.mvc.Result
import play.api.mvc.Results.BadRequest

import java.util.Base64
import scala.concurrent.{ExecutionContext, Future}

object HMACUtils {
  val Algo = Map(
    "HMAC-SHA1"   -> "HmacSHA1",
    "HMAC-SHA256" -> "HmacSHA256",
    "HMAC-SHA384" -> "HmacSHA384",
    "HMAC-SHA512" -> "HmacSHA512"
  )
}

// MIGRATED
class HMACCallerPlugin extends RequestTransformer {

  private val logger = Logger("otoroshi-plugins-hmac-caller-plugin")

  override def name: String = "HMAC caller plugin"

  override def configFlow: Seq[String] = Seq(
    "secret",
    "algo",
    "authorizationHeader"
  )

  override def configSchema =
    Some(
      Json.obj(
        "secret"              -> Json.obj(
          "type"  -> "string",
          "props" -> Json.obj(
            "label" -> "Secret to sign and verify signed content of headers. By default, the secret of the api key is used."
          )
        ),
        "algo"                -> Json.obj(
          "type"  -> "select",
          "props" -> Json.obj(
            "label"          -> "Algo to sign requests",
            "defaultValue"   -> "HmacSHA512",
            "possibleValues" -> Seq(
              Json.obj("value" -> "HMAC-SHA1", "label"   -> "HMAC-SHA1"),
              Json.obj("value" -> "HMAC-SHA256", "label" -> "HMAC-SHA256"),
              Json.obj("value" -> "HMAC-SHA384", "label" -> "HMAC-SHA384"),
              Json.obj("value" -> "HMAC-SHA512", "label" -> "HMAC-SHA512")
            )
          )
        ),
        "authorizationHeader" -> Json.obj(
          "type"  -> "select",
          "props" -> Json.obj(
            "label"          -> "Header used to send HMAC signature",
            "defaultValue"   -> "Authorization",
            "possibleValues" -> Seq(
              Json.obj("value" -> "Authorization", "label"       -> "Authorization"),
              Json.obj("value" -> "Proxy-Authorization", "label" -> "Proxy-Authorization")
            )
          )
        )
      )
    )

  override def description: Option[String] = {
    Some(
      s"""This plugin can be used to call a "protected" api by an HMAC signature. It will adds a signature with the secret configured on the plugin.
         | The signature string will always the content of the header list listed in the plugin configuration.
         |
         |```json
         |${Json.prettyPrint(defaultConfig.get)}
         |```
      """.stripMargin
    )
  }

  override def defaultConfig: Option[JsObject] = Some(
    Json.obj(
      "HMACCallerPlugin" -> Json.obj(
        "secret" -> "my-defaut-secret",
        "algo"   -> "HMAC-SHA512"
      )
    )
  )

  override def configRoot: Option[String] = "HMACCallerPlugin".some

  override def visibility: NgPluginVisibility    = NgPluginVisibility.NgUserLand
  override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Authentication)
  override def steps: Seq[NgStep]                = Seq(NgStep.TransformRequest)

  override def transformRequestWithCtx(
      context: TransformerRequestContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, HttpRequest]] = {
    val config = context.configFor("HMACCallerPlugin")
    (config \ "secret").asOpt[String] match {
      case None         =>
        if (logger.isDebugEnabled) logger.debug("No api key found and no secret found in configuration of the plugin")
        FastFuture.successful(Left(BadRequest(Json.obj("error" -> "Bad parameters"))))
      case Some(secret) =>
        val algorithm = (config \ "algorithm").asOpt[String].getOrElse("HMAC-SHA512")

        val authorizationHeader = (config \ "authorizationHeader").asOpt[String].getOrElse("Authorization")

        val signingString = System.currentTimeMillis().toString
        val signature     =
          Base64.getEncoder.encodeToString(Signatures.hmac(HMACUtils.Algo(algorithm), signingString, secret))

        if (logger.isDebugEnabled) logger.debug(s"Secret used : $secret")
        if (logger.isDebugEnabled) logger.debug(s"Signature send : $signature")
        if (logger.isDebugEnabled) logger.debug(s"Algorithm used : $algorithm")
        if (logger.isDebugEnabled) logger.debug(s"Date generated : $signingString")
        if (logger.isDebugEnabled)
          logger.debug(
            s"Send Authorization header : ${s"$authorizationHeader" -> s"""hmac algorithm="${algorithm.toLowerCase}", headers="Date", signature="$signature""""}"
          )

        context.otoroshiRequest
          .copy(headers =
            context.otoroshiRequest.headers ++ Map(
              "Date"              -> signingString,
              authorizationHeader -> s"""hmac algorithm="${algorithm.toLowerCase}", headers="Date", signature="$signature""""
            )
          )
          .right
          .future
    }
  }
}

// MIGRATED
class HMACValidator extends AccessValidator {

  private val logger = Logger("otoroshi-plugins-hmac-access-validator-plugin")

  override def name: String = "HMAC access validator"

  override def configFlow: Seq[String] = Seq("secret")

  override def configSchema =
    Some(
      Json.obj(
        "secret" -> Json.obj(
          "type"  -> "string",
          "props" -> Json.obj(
            "label" -> "[Optional] Secret to sign and verify signed content of headers"
          )
        )
      )
    )

  override def description: Option[String] = {
    Some(
      s"""This plugin can be used to check if a HMAC signature is present and valid in Authorization header.
         |
         |```json
         |${Json.prettyPrint(defaultConfig.get)}
         |```
      """.stripMargin
    )
  }

  override def defaultConfig: Option[JsObject] = Some(
    Json.obj(
      "HMACAccessValidator" -> Json.obj(
        "secret" -> ""
      )
    )
  )

  override def configRoot: Option[String] = "HMACAccessValidator".some

  override def visibility: NgPluginVisibility    = NgPluginVisibility.NgUserLand
  override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.AccessControl)
  override def steps: Seq[NgStep]                = Seq(NgStep.ValidateAccess)

  private def checkHMACSignature(authorization: String, context: AccessContext, secret: String) = {
    val params = authorization
      .replace("hmac ", "")
      .replace("\"", "")
      .trim()
      .split(",")
      .toSeq
      .map(_.split("=", 2))
      .map(r => r(0).trim -> r(1).trim)
      .toMap

    val algorithm            = params.getOrElse("algorithm", "HMAC-SHA256")
    val signature            = params("signature")
    val headers: Seq[String] = params.get("headers").map(_.split(" ").toSeq).getOrElse(Seq.empty[String])
    val signingValues        = context.request.headers.headers.filter(p => headers.contains(p._1)).map(_._2)
    val signingString        = signingValues.mkString(" ")

    if (logger.isDebugEnabled) logger.debug(s"Secret used : $secret")
    if (logger.isDebugEnabled) logger.debug(s"Signature generated : ${Base64.getEncoder
      .encodeToString(Signatures.hmac(HMACUtils.Algo(algorithm.toUpperCase), signingString, secret))}")
    if (logger.isDebugEnabled) logger.debug(s"Signature received : $signature")
    if (logger.isDebugEnabled) logger.debug(s"Algorithm used : $algorithm")

    if (signingValues.size != headers.size)
      FastFuture.successful(false)
    else if (
      Base64.getEncoder.encodeToString(
        Signatures.hmac(HMACUtils.Algo(algorithm.toUpperCase), signingString, secret)
      ) == signature
    )
      FastFuture.successful(true)
    else
      FastFuture.successful(false)
  }

  override def canAccess(context: AccessContext)(implicit env: Env, ec: ExecutionContext): Future[Boolean] =
    ((context.configFor("HMACAccessValidator") \ "secret").asOpt[String] match {
      case Some(value) if value.nonEmpty => Some(value)
      case _                             => context.attrs.get(otoroshi.plugins.Keys.ApiKeyKey).map(_.clientSecret)
    }) match {
      case None         =>
        if (logger.isDebugEnabled) logger.debug("No api key found and no secret found in configuration of the plugin")
        FastFuture.successful(false)
      case Some(secret) =>
        (context.request.headers.get("Authorization"), context.request.headers.get("Proxy-Authorization")) match {
          case (Some(authorization), None) => checkHMACSignature(authorization, context, secret)
          case (None, Some(authorization)) => checkHMACSignature(authorization, context, secret)
          case (_, _)                      =>
            if (logger.isDebugEnabled) logger.debug("Missing authorization header")
            FastFuture.successful(false)
        }
    }

  override def documentation: Option[String] = Some(
    s"""
     | The HMAC signature needs to be set on the `Authorization` or `Proxy-Authorization` header.
     | The format of this header should be : `hmac algorithm="", headers="
", signature=""` | As example, a simple nodeJS call with the expected header | ```js | const crypto = require('crypto'); | const fetch = require('node-fetch'); | | const date = new Date() | const secret = "my-secret" // equal to the api key secret by default | | const algo = "sha512" | const signature = crypto.createHmac(algo, secret) | .update(date.getTime().toString()) | .digest('base64'); | | fetch('http://myservice.oto.tools:9999/api/test', { | headers: { | "Otoroshi-Client-Id": "my-id", | "Otoroshi-Client-Secret": "my-secret", | "Date": date.getTime().toString(), | "Authorization": `hmac algorithm="hmac-$${algo}", headers="Date", signature="$${signature}"`, | "Accept": "application/json" | } | }) | .then(r => r.json()) | .then(console.log) | ``` | In this example, we have an Otoroshi service deployed on http://myservice.oto.tools:9999/api/test, protected by api keys. | The secret used is the secret of the api key (by default, but you can change it and define a secret on the plugin configuration). | We send the base64 encoded date of the day, signed by the secret, in the Authorization header. We specify the headers signed and the type of algorithm used. | You can sign more than one header but you have to list them in the headers fields (each one separate by a space, example : headers="Date KeyId"). | The algorithm used can be HMAC-SHA1, HMAC-SHA256, HMAC-SHA384 or HMAC-SHA512. |""".stripMargin ) }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy