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

next.plugins.apikey.scala Maven / Gradle / Ivy

package otoroshi.next.plugins

import akka.Done
import akka.stream.Materializer
import com.github.blemale.scaffeine.{Cache, Scaffeine}
import com.google.common.base.Charsets
import otoroshi.env.Env
import otoroshi.gateway.Errors
import otoroshi.models._
import otoroshi.next.plugins.api._
import otoroshi.next.utils.JsonHelpers
import otoroshi.script.PreRoutingError
import otoroshi.security.OtoroshiClaim
import otoroshi.utils.syntax.implicits._
import play.api.libs.json._
import play.api.mvc.{Result, Results}

import scala.concurrent.duration.DurationInt
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success, Try}

case class NgLegacyApikeyCallConfig(
    publicPatterns: Seq[String] = Seq.empty,
    privatePatterns: Seq[String] = Seq.empty,
    config: NgApikeyCallsConfig
) extends NgPluginConfig {
  override def json: JsValue = NgLegacyApikeyCallConfig.format.writes(this)
}
object NgLegacyApikeyCallConfig {
  val default = NgLegacyApikeyCallConfig(Seq.empty, Seq.empty, NgApikeyCallsConfig())
  val format  = new Format[NgLegacyApikeyCallConfig] {
    override def writes(o: NgLegacyApikeyCallConfig): JsValue             = Json.obj(
      "public_patterns"  -> o.publicPatterns,
      "private_patterns" -> o.privatePatterns
    ) ++ o.config.json.asObject
    override def reads(json: JsValue): JsResult[NgLegacyApikeyCallConfig] = Try {
      NgLegacyApikeyCallConfig(
        publicPatterns = json.select("public_patterns").asOpt[Seq[String]].getOrElse(Seq.empty),
        privatePatterns = json.select("private_patterns").asOpt[Seq[String]].getOrElse(Seq.empty),
        config = NgApikeyCallsConfig.format.reads(json).asOpt.getOrElse(NgApikeyCallsConfig())
      )
    } match {
      case Failure(e)     => JsError(e.getMessage)
      case Success(value) => JsSuccess(value)
    }
  }
}

class NgLegacyApikeyCall extends NgAccessValidator with NgRequestTransformer with NgRouteMatcher {

  private val configCache: Cache[String, NgLegacyApikeyCallConfig] = Scaffeine()
    .expireAfterWrite(5.seconds)
    .maximumSize(1000)
    .build()

  private val configReads: Reads[NgLegacyApikeyCallConfig] = NgLegacyApikeyCallConfig.format

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

  override def multiInstance: Boolean                      = true
  override def core: Boolean                               = true
  override def usesCallbacks: Boolean                      = false
  override def transformsRequest: Boolean                  = true
  override def transformsResponse: Boolean                 = false
  override def transformsError: Boolean                    = false
  override def isTransformRequestAsync: Boolean            = false
  override def isTransformResponseAsync: Boolean           = false
  override def isAccessAsync: Boolean                      = true
  override def name: String                                = "Legacy apikeys"
  override def description: Option[String]                 =
    "This plugin expects to find an apikey to allow the request to pass. This plugin behaves exactly like the service descriptor does".some
  override def defaultConfigObject: Option[NgPluginConfig] = NgLegacyApikeyCallConfig.default.some

  override def matches(ctx: NgRouteMatcherContext)(implicit env: Env): Boolean = {
    val plugin = env.scriptManager
      .getAnyScript[NgRouteMatcher](NgPluginHelper.pluginId[ApikeyCalls])(env.otoroshiExecutionContext)
      .right
      .get
    plugin.matches(ctx)(env)
  }
  override def transformRequestSync(
      ctx: NgTransformerRequestContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Either[Result, NgPluginHttpRequest] = {
    val plugin = env.scriptManager
      .getAnyScript[NgRequestTransformer](NgPluginHelper.pluginId[ApikeyCalls])(env.otoroshiExecutionContext)
      .right
      .get
    plugin.transformRequestSync(ctx)(env, ec, mat)
  }
  override def access(ctx: NgAccessContext)(implicit env: Env, ec: ExecutionContext): Future[NgAccess] = {
    val plugin     = env.scriptManager
      .getAnyScript[NgAccessValidator](NgPluginHelper.pluginId[ApikeyCalls])(env.otoroshiExecutionContext)
      .right
      .get
    val config     = configCache.get(
      ctx.route.cacheableId,
      _ => configReads.reads(ctx.config).getOrElse(NgLegacyApikeyCallConfig.default)
    )
    val descriptor =
      ctx.route.legacy.copy(publicPatterns = config.publicPatterns, privatePatterns = config.privatePatterns)
    val req        = ctx.request
    if (!descriptor.strictlyPrivate && ctx.attrs.get(otoroshi.plugins.Keys.UserKey).nonEmpty) {
      NgAccess.NgAllowed.vfuture
    } else if (descriptor.isUriPublic(req.path)) {
      if (
        env.detectApiKeySooner && descriptor.detectApiKeySooner && ApiKeyHelper
          .detectApiKey(req, descriptor, ctx.attrs)
      ) {
        plugin.access(ctx)(env, ec)
      } else {
        NgAccess.NgAllowed.vfuture
      }
    } else {
      plugin.access(ctx)(env, ec)
    }
  }
}

class ApikeyCalls extends NgAccessValidator with NgRequestTransformer with NgRouteMatcher {

  private val configCache: Cache[String, NgApikeyCallsConfig] = Scaffeine()
    .expireAfterWrite(5.seconds)
    .maximumSize(1000)
    .build()

  private val configReads: Reads[NgApikeyCallsConfig] = NgApikeyCallsConfig.format

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

  override def multiInstance: Boolean                      = true
  override def core: Boolean                               = true
  override def usesCallbacks: Boolean                      = false
  override def transformsRequest: Boolean                  = true
  override def transformsResponse: Boolean                 = false
  override def transformsError: Boolean                    = false
  override def isTransformRequestAsync: Boolean            = false
  override def isTransformResponseAsync: Boolean           = false
  override def isAccessAsync: Boolean                      = true
  override def name: String                                = "Apikeys"
  override def description: Option[String]                 = "This plugin expects to find an apikey to allow the request to pass".some
  override def defaultConfigObject: Option[NgPluginConfig] = NgApikeyCallsConfig().some

  override def matches(ctx: NgRouteMatcherContext)(implicit env: Env): Boolean = {
    val config =
      configCache.get(ctx.route.cacheableId, _ => configReads.reads(ctx.config).getOrElse(NgApikeyCallsConfig()))
    if (config.routing.enabled) {
      if (config.routing.hasNoRoutingConstraints) {
        true
      } else {
        ApiKeyHelper.detectApikeyTuple(ctx.request, config.legacy, ctx.attrs) match {
          case None        => true
          case Some(tuple) =>
            ctx.attrs.put(otoroshi.next.plugins.Keys.PreExtractedApikeyTupleKey -> tuple)
            ApiKeyHelper.validateApikeyTuple(ctx.request, tuple, config.legacy, ctx.route.id, ctx.attrs).applyOn {
              either =>
                ctx.attrs.put(otoroshi.next.plugins.Keys.PreExtractedApikeyKey -> either)
                either
            } match {
              case Left(_)       => false
              case Right(apikey) => apikey.matchRouting(config.legacy.routing)
            }
        }
      }
    } else {
      true
    }
  }

  override def access(ctx: NgAccessContext)(implicit env: Env, ec: ExecutionContext): Future[NgAccess] = {
    val config    =
      configCache.get(ctx.route.cacheableId, _ => configReads.reads(ctx.config).getOrElse(NgApikeyCallsConfig()))
    val maybeUser = ctx.attrs.get(otoroshi.plugins.Keys.UserKey)
    (config.passWithUser match {
      case true  =>
        maybeUser match {
          case Some(_) => true.future
          case None    =>
            PrivateAppsUserHelper.isPrivateAppsSessionValid(ctx.request, ctx.route.legacy, ctx.attrs).map {
              case Some(user) =>
                ctx.attrs.put(otoroshi.plugins.Keys.UserKey -> user)
                true
              case None       => false
            }
        }
      case false => false.future
    }).flatMap { pass =>
      ctx.attrs.get(otoroshi.plugins.Keys.ApiKeyKey) match {
        case None if config.validate && config.mandatory && !pass   => {
          // Here are 2 + 12 datastore calls to handle quotas
          val routeId = ctx.route.cacheableId // handling route groups
          ApiKeyHelper
            .passWithApiKeyFromCache(
              ctx.request,
              config.legacy,
              ctx.attrs,
              routeId,
              config.updateQuotas,
              config.routing.enabled
            )
            .map {
              case Left(result)  => NgAccess.NgDenied(result)
              case Right(apikey) =>
                ctx.attrs.put(otoroshi.plugins.Keys.ApiKeyKey -> apikey)
                NgAccess.NgAllowed
            }
        }
        case None if config.validate && !config.mandatory && !pass  => {
          // Here are 2 + 12 datastore calls to handle quotas
          val routeId = ctx.route.cacheableId // handling route groups
          ApiKeyHelper
            .passWithApiKeyFromCache(
              ctx.request,
              config.legacy,
              ctx.attrs,
              routeId,
              config.updateQuotas,
              config.routing.enabled
            )
            .map {
              case Left(result)
                  if result.header.status == 400 && result.header.headers
                    .get(env.Headers.OtoroshiErrorMsg)
                    .contains("no apikey") =>
                NgAccess.NgAllowed
              case Left(result)  =>
                NgAccess.NgDenied(result)
              case Right(apikey) =>
                ctx.attrs.put(otoroshi.plugins.Keys.ApiKeyKey -> apikey)
                NgAccess.NgAllowed
            }
        }
        case None if !config.validate && !config.mandatory && !pass => {
          // Here are 2 + 12 datastore calls to handle quotas
          val routeId = ctx.route.cacheableId // handling route groups
          ApiKeyHelper
            .passWithApiKeyFromCache(
              ctx.request,
              config.legacy,
              ctx.attrs,
              routeId,
              config.updateQuotas,
              config.routing.enabled
            )
            .map {
              case Left(result)  =>
                NgAccess.NgAllowed
              case Right(apikey) =>
                ctx.attrs.put(otoroshi.plugins.Keys.ApiKeyKey -> apikey)
                NgAccess.NgAllowed
            }
        }
        case _                                                      => NgAccess.NgAllowed.vfuture
      }
    }
  }

  override def transformRequestSync(
      ctx: NgTransformerRequestContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Either[Result, NgPluginHttpRequest] = {
    val config =
      configCache.get(ctx.route.cacheableId, _ => configReads.reads(ctx.config).getOrElse(NgApikeyCallsConfig()))
    if (config.wipeBackendRequest) {
      ctx.attrs.get(otoroshi.next.plugins.Keys.PreExtractedApikeyTupleKey) match {
        case Some(ApikeyTuple(_, _, _, Some(location), _)) => {
          location.kind match {
            case ApikeyLocationKind.Header =>
              ctx.otoroshiRequest
                .copy(headers =
                  ctx.otoroshiRequest.headers.filterNot(_._1.toLowerCase() == location.name.toLowerCase())
                )
                .right
            case ApikeyLocationKind.Query  => {
              val uri      = ctx.otoroshiRequest.uri
              val newQuery = uri.rawQueryString.map(_ => uri.query().filterNot(_._1 == location.name).toString())
              val newUrl   = uri.copy(rawQueryString = newQuery).toString()
              ctx.otoroshiRequest.copy(url = newUrl).right
            }
            case ApikeyLocationKind.Cookie =>
              ctx.otoroshiRequest.copy(cookies = ctx.otoroshiRequest.cookies.filterNot(_.name == location.name)).right
          }
        }
        case _                                             => ctx.otoroshiRequest.right
      }
    } else {
      ctx.otoroshiRequest.right
    }
  }
}

case class NgApikeyExtractorBasic(
    enabled: Boolean = true,
    headerName: Option[String] = None,
    queryName: Option[String] = None
) {
  lazy val legacy: BasicAuthConstraints = BasicAuthConstraints(
    enabled = enabled,
    headerName = headerName,
    queryName = queryName
  )
  def json: JsValue                     = Json.obj(
    "enabled"     -> enabled,
    "header_name" -> headerName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
    "query_name"  -> queryName.map(JsString.apply).getOrElse(JsNull).as[JsValue]
  )
}

object NgApikeyExtractorBasic {
  val format                                                      = new Format[NgApikeyExtractorBasic] {
    override def writes(o: NgApikeyExtractorBasic): JsValue             = o.json
    override def reads(json: JsValue): JsResult[NgApikeyExtractorBasic] = JsonHelpers.reader {
      NgApikeyExtractorBasic(
        enabled = (json \ "enabled").asOpt[Boolean].getOrElse(true),
        headerName = (json \ "header_name").asOpt[String].filterNot(_.trim.isEmpty),
        queryName = (json \ "query_name").asOpt[String].filterNot(_.trim.isEmpty)
      )
    }
  }
  def fromLegacy(s: BasicAuthConstraints): NgApikeyExtractorBasic = NgApikeyExtractorBasic(
    enabled = s.enabled,
    headerName = s.headerName,
    queryName = s.queryName
  )
}

case class NgApikeyExtractorClientId(
    enabled: Boolean = true,
    headerName: Option[String] = None,
    queryName: Option[String] = None
) {
  lazy val legacy: ClientIdAuthConstraints = ClientIdAuthConstraints(
    enabled = enabled,
    headerName = headerName,
    queryName = queryName
  )
  def json: JsValue                        = Json.obj(
    "enabled"     -> enabled,
    "header_name" -> headerName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
    "query_name"  -> queryName.map(JsString.apply).getOrElse(JsNull).as[JsValue]
  )
}

object NgApikeyExtractorClientId {
  val format                                                            = new Format[NgApikeyExtractorClientId] {
    override def writes(o: NgApikeyExtractorClientId): JsValue             = o.json
    override def reads(json: JsValue): JsResult[NgApikeyExtractorClientId] = JsonHelpers.reader {
      NgApikeyExtractorClientId(
        enabled = (json \ "enabled").asOpt[Boolean].getOrElse(true),
        headerName = (json \ "header_name").asOpt[String].filterNot(_.trim.isEmpty),
        queryName = (json \ "query_name").asOpt[String].filterNot(_.trim.isEmpty)
      )
    }
  }
  def fromLegacy(s: ClientIdAuthConstraints): NgApikeyExtractorClientId = NgApikeyExtractorClientId(
    enabled = s.enabled,
    headerName = s.headerName,
    queryName = s.queryName
  )
}

case class NgApikeyExtractorCustomHeaders(
    enabled: Boolean = true,
    clientIdHeaderName: Option[String] = None,
    clientSecretHeaderName: Option[String] = None
) {
  lazy val legacy: CustomHeadersAuthConstraints = CustomHeadersAuthConstraints(
    enabled = enabled,
    clientIdHeaderName = clientIdHeaderName,
    clientSecretHeaderName = clientSecretHeaderName
  )
  def json: JsValue                             = Json.obj(
    "enabled"                   -> enabled,
    "client_id_header_name"     -> clientIdHeaderName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
    "client_secret_header_name" -> clientSecretHeaderName.map(JsString.apply).getOrElse(JsNull).as[JsValue]
  )
}

object NgApikeyExtractorCustomHeaders {
  val format                                                                      = new Format[NgApikeyExtractorCustomHeaders] {
    override def writes(o: NgApikeyExtractorCustomHeaders): JsValue             = o.json
    override def reads(json: JsValue): JsResult[NgApikeyExtractorCustomHeaders] = JsonHelpers.reader {
      NgApikeyExtractorCustomHeaders(
        enabled = (json \ "enabled").asOpt[Boolean].getOrElse(true),
        clientIdHeaderName = (json \ "client_id_header_name").asOpt[String].filterNot(_.trim.isEmpty),
        clientSecretHeaderName = (json \ "client_secret_header_name").asOpt[String].filterNot(_.trim.isEmpty)
      )
    }
  }
  def fromLegacy(s: CustomHeadersAuthConstraints): NgApikeyExtractorCustomHeaders = NgApikeyExtractorCustomHeaders(
    enabled = s.enabled,
    clientIdHeaderName = s.clientIdHeaderName,
    clientSecretHeaderName = s.clientSecretHeaderName
  )
}

case class NgApikeyExtractorJwt(
    enabled: Boolean = true,
    secretSigned: Boolean = true,
    keyPairSigned: Boolean = true,
    includeRequestAttrs: Boolean = false,
    maxJwtLifespanSec: Option[Long] = None, //Some(10 * 365 * 24 * 60 * 60),
    headerName: Option[String] = None,
    queryName: Option[String] = None,
    cookieName: Option[String] = None
) {
  lazy val legacy: JwtAuthConstraints = JwtAuthConstraints(
    enabled = enabled,
    secretSigned = secretSigned,
    keyPairSigned = keyPairSigned,
    includeRequestAttributes = includeRequestAttrs,
    maxJwtLifespanSecs = maxJwtLifespanSec,
    headerName = headerName,
    queryName = queryName,
    cookieName = cookieName
  )
  def json: JsValue                   = Json.obj(
    "enabled"               -> enabled,
    "secret_signed"         -> secretSigned,
    "keypair_signed"        -> keyPairSigned,
    "include_request_attrs" -> includeRequestAttrs,
    "max_jwt_lifespan_sec"  -> maxJwtLifespanSec.map(l => JsNumber(BigDecimal.exact(l))).getOrElse(JsNull).as[JsValue],
    "header_name"           -> headerName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
    "query_name"            -> queryName.map(JsString.apply).getOrElse(JsNull).as[JsValue],
    "cookie_name"           -> cookieName.map(JsString.apply).getOrElse(JsNull).as[JsValue]
  )
}

object NgApikeyExtractorJwt {
  val format                                                  = new Format[NgApikeyExtractorJwt] {
    override def writes(o: NgApikeyExtractorJwt): JsValue             = o.json
    override def reads(json: JsValue): JsResult[NgApikeyExtractorJwt] = JsonHelpers.reader {
      NgApikeyExtractorJwt(
        enabled = (json \ "enabled").asOpt[Boolean].getOrElse(true),
        secretSigned = (json \ "secret_signed").asOpt[Boolean].getOrElse(true),
        keyPairSigned = (json \ "keypair_signed").asOpt[Boolean].getOrElse(true),
        includeRequestAttrs = (json \ "include_request_attrs").asOpt[Boolean].getOrElse(false),
        maxJwtLifespanSec =
          (json \ "max_jwt_lifespan_sec").asOpt[Long].filter(_ > -1), //.getOrElse(10 * 365 * 24 * 60 * 60),
        headerName = (json \ "header_name").asOpt[String].filterNot(_.trim.isEmpty),
        queryName = (json \ "query_name").asOpt[String].filterNot(_.trim.isEmpty),
        cookieName = (json \ "cookie_name").asOpt[String].filterNot(_.trim.isEmpty)
      )
    }
  }
  def fromLegacy(s: JwtAuthConstraints): NgApikeyExtractorJwt = NgApikeyExtractorJwt(
    enabled = s.enabled,
    secretSigned = s.secretSigned,
    keyPairSigned = s.keyPairSigned,
    includeRequestAttrs = s.includeRequestAttributes,
    maxJwtLifespanSec = s.maxJwtLifespanSecs,
    headerName = s.headerName,
    queryName = s.queryName,
    cookieName = s.cookieName
  )
}

case class NgApikeyMatcher(
    enabled: Boolean = false,
    noneTagIn: Seq[String] = Seq.empty,
    oneTagIn: Seq[String] = Seq.empty,
    allTagsIn: Seq[String] = Seq.empty,
    noneMetaIn: Map[String, String] = Map.empty,
    oneMetaIn: Map[String, String] = Map.empty,
    allMetaIn: Map[String, String] = Map.empty,
    noneMetaKeysIn: Seq[String] = Seq.empty,
    oneMetaKeyIn: Seq[String] = Seq.empty,
    allMetaKeysIn: Seq[String] = Seq.empty
) extends {
  lazy val legacy: ApiKeyRouteMatcher       = ApiKeyRouteMatcher(
    noneTagIn = noneTagIn,
    oneTagIn = oneTagIn,
    allTagsIn = allTagsIn,
    noneMetaIn = noneMetaIn,
    oneMetaIn = oneMetaIn,
    allMetaIn = allMetaIn,
    noneMetaKeysIn = noneMetaKeysIn,
    oneMetaKeyIn = oneMetaKeyIn,
    allMetaKeysIn = allMetaKeysIn
  )
  def json: JsValue                         = gentleJson
  def gentleJson: JsValue                   = Json
    .obj("enabled" -> enabled)
    .applyOnIf(noneTagIn.nonEmpty)(obj => obj ++ Json.obj("none_tagIn" -> noneTagIn))
    .applyOnIf(oneTagIn.nonEmpty)(obj => obj ++ Json.obj("one_tag_in" -> oneTagIn))
    .applyOnIf(allTagsIn.nonEmpty)(obj => obj ++ Json.obj("all_tags_in" -> allTagsIn))
    .applyOnIf(noneMetaIn.nonEmpty)(obj => obj ++ Json.obj("none_meta_in" -> noneMetaIn))
    .applyOnIf(oneMetaIn.nonEmpty)(obj => obj ++ Json.obj("one_meta_in" -> oneMetaIn))
    .applyOnIf(allMetaIn.nonEmpty)(obj => obj ++ Json.obj("all_meta_in" -> allMetaIn))
    .applyOnIf(noneMetaKeysIn.nonEmpty)(obj => obj ++ Json.obj("none_meta_keys_in" -> noneMetaKeysIn))
    .applyOnIf(oneMetaKeyIn.nonEmpty)(obj => obj ++ Json.obj("one_meta_key_in" -> oneMetaKeyIn))
    .applyOnIf(allMetaKeysIn.nonEmpty)(obj => obj ++ Json.obj("all_meta_keys_in" -> allMetaKeysIn))
  lazy val isActive: Boolean                = !hasNoRoutingConstraints
  lazy val hasNoRoutingConstraints: Boolean =
    oneMetaIn.isEmpty &&
    allMetaIn.isEmpty &&
    oneTagIn.isEmpty &&
    allTagsIn.isEmpty &&
    noneTagIn.isEmpty &&
    noneMetaIn.isEmpty &&
    oneMetaKeyIn.isEmpty &&
    allMetaKeysIn.isEmpty &&
    noneMetaKeysIn.isEmpty
}

object NgApikeyMatcher {
  val format                                             = new Format[NgApikeyMatcher] {
    override def writes(o: NgApikeyMatcher): JsValue             = o.json
    override def reads(json: JsValue): JsResult[NgApikeyMatcher] = JsonHelpers.reader {
      NgApikeyMatcher(
        enabled = (json \ "enabled").asOpt[Boolean].getOrElse(false),
        noneTagIn = (json \ "none_tag_in").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
        oneTagIn = (json \ "one_tag_in").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
        allTagsIn = (json \ "all_tags_in").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
        noneMetaIn = (json \ "none_meta_in").asOpt[Map[String, String]].getOrElse(Map.empty[String, String]),
        oneMetaIn = (json \ "one_meta_in").asOpt[Map[String, String]].getOrElse(Map.empty[String, String]),
        allMetaIn = (json \ "all_meta_in").asOpt[Map[String, String]].getOrElse(Map.empty[String, String]),
        noneMetaKeysIn = (json \ "none_meta_keys_in").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
        oneMetaKeyIn = (json \ "one_meta_key_in").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
        allMetaKeysIn = (json \ "all_meta_keys_in").asOpt[Seq[String]].getOrElse(Seq.empty[String])
      )
    }
  }
  def fromLegacy(s: ApiKeyRouteMatcher): NgApikeyMatcher = NgApikeyMatcher(
    enabled = false,
    noneTagIn = s.noneTagIn,
    oneTagIn = s.oneTagIn,
    allTagsIn = s.allTagsIn,
    noneMetaIn = s.noneMetaIn,
    oneMetaIn = s.oneMetaIn,
    allMetaIn = s.allMetaIn,
    noneMetaKeysIn = s.noneMetaKeysIn,
    oneMetaKeyIn = s.oneMetaKeyIn,
    allMetaKeysIn = s.allMetaKeysIn
  ).applyOnWithPredicate(_.isActive)(_.copy(enabled = true))
}

case class NgApikeyExtractors(
    basic: NgApikeyExtractorBasic = NgApikeyExtractorBasic(),
    customHeaders: NgApikeyExtractorCustomHeaders = NgApikeyExtractorCustomHeaders(),
    clientId: NgApikeyExtractorClientId = NgApikeyExtractorClientId(),
    jwt: NgApikeyExtractorJwt = NgApikeyExtractorJwt()
) {
  def json: JsValue = Json.obj(
    "basic"          -> basic.json,
    "custom_headers" -> customHeaders.json,
    "client_id"      -> clientId.json,
    "jwt"            -> jwt.json
  )
}

object NgApikeyExtractors {
  val format = new Format[NgApikeyExtractors] {
    override def writes(o: NgApikeyExtractors): JsValue             = o.json
    override def reads(json: JsValue): JsResult[NgApikeyExtractors] = JsonHelpers.reader {
      NgApikeyExtractors(
        basic = (json \ "basic").asOpt(NgApikeyExtractorBasic.format).getOrElse(NgApikeyExtractorBasic()),
        customHeaders = (json \ "custom_headers")
          .asOpt(NgApikeyExtractorCustomHeaders.format)
          .getOrElse(NgApikeyExtractorCustomHeaders()),
        clientId = (json \ "client_id").asOpt(NgApikeyExtractorClientId.format).getOrElse(NgApikeyExtractorClientId()),
        jwt = (json \ "jwt").asOpt(NgApikeyExtractorJwt.format).getOrElse(NgApikeyExtractorJwt())
      )
    }
  }
}

case class NgApikeyCallsConfig(
    extractors: NgApikeyExtractors = NgApikeyExtractors(),
    routing: NgApikeyMatcher = NgApikeyMatcher(),
    wipeBackendRequest: Boolean = true,
    validate: Boolean = true,
    mandatory: Boolean = true,
    passWithUser: Boolean = false,
    updateQuotas: Boolean = true
) extends NgPluginConfig {
  def json: JsValue                  = Json.obj(
    "extractors"           -> extractors.json,
    "routing"              -> routing.json,
    "validate"             -> validate,
    "mandatory"            -> mandatory,
    "pass_with_user"       -> passWithUser,
    "wipe_backend_request" -> wipeBackendRequest,
    "update_quotas"        -> updateQuotas
  )
  lazy val legacy: ApiKeyConstraints = ApiKeyConstraints(
    basicAuth = extractors.basic.legacy,
    customHeadersAuth = extractors.customHeaders.legacy,
    clientIdAuth = extractors.clientId.legacy,
    jwtAuth = extractors.jwt.legacy,
    routing = routing.legacy
  )
}

object NgApikeyCallsConfig {
  val format                                                = new Format[NgApikeyCallsConfig] {
    override def writes(o: NgApikeyCallsConfig): JsValue             = o.json
    override def reads(json: JsValue): JsResult[NgApikeyCallsConfig] = Try {
      NgApikeyCallsConfig(
        extractors = (json \ "extractors").asOpt(NgApikeyExtractors.format).getOrElse(NgApikeyExtractors()),
        routing = (json \ "routing").asOpt(NgApikeyMatcher.format).getOrElse(NgApikeyMatcher()),
        validate = (json \ "validate").asOpt[Boolean].getOrElse(true),
        mandatory = (json \ "mandatory").asOpt[Boolean].getOrElse(true),
        passWithUser = (json \ "pass_with_user").asOpt[Boolean].getOrElse(false),
        wipeBackendRequest = (json \ "wipe_backend_request").asOpt[Boolean].getOrElse(true),
        updateQuotas = (json \ "update_quotas").asOpt[Boolean].getOrElse(true)
      )
    } match {
      case Success(value) => JsSuccess(value)
      case Failure(err)   =>
        ApiKeyConstraints.format.reads(json) match {
          case s @ JsSuccess(_, _) => s.map(NgApikeyCallsConfig.fromLegacy)
          case e @ JsError(_)      =>
            err.printStackTrace()
            e
        }
    }
  }
  def fromLegacy(o: ApiKeyConstraints): NgApikeyCallsConfig = NgApikeyCallsConfig(
    extractors = NgApikeyExtractors(
      basic = NgApikeyExtractorBasic.fromLegacy(o.basicAuth),
      customHeaders = NgApikeyExtractorCustomHeaders.fromLegacy(o.customHeadersAuth),
      clientId = NgApikeyExtractorClientId.fromLegacy(o.clientIdAuth),
      jwt = NgApikeyExtractorJwt.fromLegacy(o.jwtAuth)
    ),
    routing = NgApikeyMatcher.fromLegacy(o.routing),
    validate = true,
    passWithUser = false,
    wipeBackendRequest = true,
    updateQuotas = true
  )
}

case class ApikeyAuthModuleConfig(
    realm: Option[String] = "apikey-auth-module-realm".some,
    matcher: Option[ApiKeyRouteMatcher] = None
) extends NgPluginConfig {
  override def json: JsValue = ApikeyAuthModuleConfig.format.writes(this)
}

object ApikeyAuthModuleConfig {
  val format = new Format[ApikeyAuthModuleConfig] {
    override def writes(o: ApikeyAuthModuleConfig): JsValue = Json.obj(
      "realm"   -> o.realm,
      "matcher" -> o.matcher.map(ApiKeyRouteMatcher.format.writes)
    )

    override def reads(json: JsValue): JsResult[ApikeyAuthModuleConfig] = Try {
      ApikeyAuthModuleConfig(
        realm = json.select("realm").asOpt[String],
        matcher = ApiKeyRouteMatcher.format.reads(json.select("matcher").getOrElse(Json.obj())) match {
          case JsSuccess(value, _) => value.some
          case JsError(_)          => None
        }
      )
    } match {
      case Failure(ex)    => JsError(ex.getMessage)
      case Success(value) => JsSuccess(value)
    }
  }
}

class ApikeyAuthModule extends NgPreRouting {

  override def name: String                                = "Apikey auth module"
  override def visibility: NgPluginVisibility              = NgPluginVisibility.NgUserLand
  override def categories: Seq[NgPluginCategory]           = Seq(NgPluginCategory.AccessControl)
  override def steps: Seq[NgStep]                          = Seq(NgStep.PreRoute)
  override def multiInstance: Boolean                      = true
  override def defaultConfigObject: Option[NgPluginConfig] = ApikeyAuthModuleConfig().some
  override def description: Option[String]                 =
    "This plugin adds basic auth on service where credentials are valid apikeys on the current service.".some

  def decodeBase64(encoded: String): String = new String(OtoroshiClaim.decoder.decode(encoded), Charsets.UTF_8)

  def extractUsernamePassword(header: String): Option[(String, String)] = {
    val base64 = header.replace("Basic ", "").replace("basic ", "")
    Option(base64)
      .map(decodeBase64)
      .map(_.split(":").toSeq)
      .filter(v => v.nonEmpty && v.length > 1)
      .flatMap(a => a.headOption.map(head => (head, a.tail.mkString(":"))))
  }

  def unauthorized(config: ApikeyAuthModuleConfig) = {
    val realm = config.realm.getOrElse("apikey-auth-module-realm")
    PreRoutingError(
      body = "

not authorized

".byteString, code = 401, contentType = "text/html", headers = Map("WWW-Authenticate" -> s"""Basic realm="${realm}"""") ).asResult } def forbidden(config: ApikeyAuthModuleConfig) = { val realm = config.realm.getOrElse("apikey-auth-module-realm") PreRoutingError( body = "

forbidden

".byteString, code = 403, contentType = "text/html", headers = Map("WWW-Authenticate" -> s"""Basic realm="${realm}"""") ).asResult } def validApikey(apikey: ApiKey, routing: ApiKeyRouteMatcher): Boolean = { import otoroshi.models.SeqImplicits._ val matchOnRole: Boolean = Option(routing.oneTagIn) .filter(_.nonEmpty) .forall(tags => apikey.tags.findOne(tags)) val matchAllRoles: Boolean = Option(routing.allTagsIn) .filter(_.nonEmpty) .forall(tags => apikey.tags.findAll(tags)) val matchNoneRole: Boolean = !Option(routing.noneTagIn) .filter(_.nonEmpty) .exists(tags => apikey.tags.findOne(tags)) val matchOneMeta: Boolean = Option(routing.oneMetaIn.toSeq) .filter(_.nonEmpty) .forall(metas => apikey.metadata.toSeq.findOne(metas)) val matchAllMeta: Boolean = Option(routing.allMetaIn.toSeq) .filter(_.nonEmpty) .forall(metas => apikey.metadata.toSeq.findAll(metas)) val matchNoneMeta: Boolean = !Option(routing.noneMetaIn.toSeq) .filter(_.nonEmpty) .exists(metas => apikey.metadata.toSeq.findOne(metas)) val matchOneMetakeys: Boolean = Option(routing.oneMetaKeyIn) .filter(_.nonEmpty) .forall(keys => apikey.metadata.toSeq.map(_._1).findOne(keys)) val matchAllMetaKeys: Boolean = Option(routing.allMetaKeysIn) .filter(_.nonEmpty) .forall(keys => apikey.metadata.toSeq.map(_._1).findAll(keys)) val matchNoneMetaKeys: Boolean = !Option(routing.noneMetaKeysIn) .filter(_.nonEmpty) .exists(keys => apikey.metadata.toSeq.map(_._1).findOne(keys)) val result = Seq( matchOnRole, matchAllRoles, matchNoneRole, matchOneMeta, matchAllMeta, matchNoneMeta, matchOneMetakeys, matchAllMetaKeys, matchNoneMetaKeys ) .forall(bool => bool) result } override def preRoute( ctx: NgPreRoutingContext )(implicit env: Env, ec: ExecutionContext): Future[Either[NgPreRoutingError, Done]] = { val config = ctx .cachedConfig(internalName)(ApikeyAuthModuleConfig.format) .getOrElse(ApikeyAuthModuleConfig()) ctx.request.headers.get("Authorization") match { case Some(auth) if auth.startsWith("Basic ") => extractUsernamePassword(auth) match { case None => Left(NgPreRoutingErrorWithResult(forbidden(config))).vfuture case Some((username, password)) => env.datastores.apiKeyDataStore.findById(username).flatMap { case Some(apikey) if apikey.clientSecret == password && validApikey( apikey, config.matcher.getOrElse(ApiKeyRouteMatcher()) ) => ctx.attrs.put(otoroshi.plugins.Keys.ApiKeyKey -> apikey) Done.right.vfuture case _ => Left(NgPreRoutingErrorWithResult(unauthorized(config))).vfuture } } case _ => Left(NgPreRoutingErrorWithResult(unauthorized(config))).vfuture } } } case class NgApikeyMandatoryTagsConfig(tags: Seq[String] = Seq.empty) extends NgPluginConfig { def json: JsValue = NgApikeyMandatoryTagsConfig.format.writes(this) } object NgApikeyMandatoryTagsConfig { val format = new Format[NgApikeyMandatoryTagsConfig] { override def writes(o: NgApikeyMandatoryTagsConfig): JsValue = Json.obj( "tags" -> o.tags ) override def reads(json: JsValue): JsResult[NgApikeyMandatoryTagsConfig] = Try { NgApikeyMandatoryTagsConfig( tags = json.select("tags").asOpt[Seq[String]].getOrElse(Seq.empty) ) } match { case Failure(e) => JsError(e.getMessage) case Success(s) => JsSuccess(s) } } } class NgApikeyMandatoryTags extends NgAccessValidator { override def name: String = "Apikey mandatory tags" override def visibility: NgPluginVisibility = NgPluginVisibility.NgUserLand override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.AccessControl) override def steps: Seq[NgStep] = Seq(NgStep.ValidateAccess) override def multiInstance: Boolean = true override def defaultConfigObject: Option[NgPluginConfig] = NgApikeyMandatoryTagsConfig().some override def description: Option[String] = "This plugin checks that if an apikey is provided, there is one or more tags on it".some override def access(ctx: NgAccessContext)(implicit env: Env, ec: ExecutionContext): Future[NgAccess] = { val config = ctx .cachedConfig(internalName)(NgApikeyMandatoryTagsConfig.format) .getOrElse(NgApikeyMandatoryTagsConfig()) ctx.apikey match { case None => NgAccess.NgAllowed.vfuture case Some(apikey) => { if (apikey.tags.containsAll(config.tags)) { NgAccess.NgAllowed.vfuture } else { Errors .craftResponseResult( "forbidden", Results.Forbidden, ctx.request, None, Some("errors.no.matching.tags"), duration = ctx.report.getDurationNow(), overhead = ctx.report.getOverheadInNow(), attrs = ctx.attrs, maybeRoute = ctx.route.some ) .map(r => NgAccess.NgDenied(r)) } } } } } case class NgApikeyMandatoryMetadataConfig(metadata: Map[String, String] = Map.empty) extends NgPluginConfig { def json: JsValue = NgApikeyMandatoryMetadataConfig.format.writes(this) } object NgApikeyMandatoryMetadataConfig { val format = new Format[NgApikeyMandatoryMetadataConfig] { override def writes(o: NgApikeyMandatoryMetadataConfig): JsValue = Json.obj( "metadata" -> o.metadata ) override def reads(json: JsValue): JsResult[NgApikeyMandatoryMetadataConfig] = Try { NgApikeyMandatoryMetadataConfig( metadata = json.select("metadata").asOpt[Map[String, String]].getOrElse(Map.empty) ) } match { case Failure(e) => JsError(e.getMessage) case Success(s) => JsSuccess(s) } } } class NgApikeyMandatoryMetadata extends NgAccessValidator { override def name: String = "Apikey mandatory metadata" override def visibility: NgPluginVisibility = NgPluginVisibility.NgUserLand override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.AccessControl) override def steps: Seq[NgStep] = Seq(NgStep.ValidateAccess) override def multiInstance: Boolean = true override def defaultConfigObject: Option[NgPluginConfig] = NgApikeyMandatoryMetadataConfig().some override def description: Option[String] = "This plugin checks that if an apikey is provided, there is one or more metadata on it".some override def access(ctx: NgAccessContext)(implicit env: Env, ec: ExecutionContext): Future[NgAccess] = { val config = ctx .cachedConfig(internalName)(NgApikeyMandatoryMetadataConfig.format) .getOrElse(NgApikeyMandatoryMetadataConfig()) ctx.apikey match { case None => NgAccess.NgAllowed.vfuture case Some(apikey) => { if (apikey.metadata.containsAll(config.metadata)) { NgAccess.NgAllowed.vfuture } else { Errors .craftResponseResult( "forbidden", Results.Forbidden, ctx.request, None, Some("errors.no.matching.metadata"), duration = ctx.report.getDurationNow(), overhead = ctx.report.getOverheadInNow(), attrs = ctx.attrs, maybeRoute = ctx.route.some ) .map(r => NgAccess.NgDenied(r)) } } } } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy