Skip to content

Commit

Permalink
fix #1806, fix #1807, fix #1808, fix #1809, fix #1810
Browse files Browse the repository at this point in the history
  • Loading branch information
mathieuancelin committed Jan 4, 2024
1 parent 6a48467 commit ce92ca2
Show file tree
Hide file tree
Showing 12 changed files with 606 additions and 2 deletions.
2 changes: 2 additions & 0 deletions otoroshi/app/el/el.scala
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,15 @@ object GlobalExpressionLanguage {

case "req.fullUrl" if req.isDefined =>
s"${req.get.theProtocol(env)}://${req.get.theHost(env)}${req.get.relativeUri}"
case "req.id" if req.isDefined => req.get.id.toString
case "req.path" if req.isDefined => req.get.path
case "req.uri" if req.isDefined => req.get.relativeUri
case "req.host" if req.isDefined => req.get.theHost(env)
case "req.domain" if req.isDefined => req.get.theDomain(env)
case "req.method" if req.isDefined => req.get.method
case "req.protocol" if req.isDefined => req.get.theProtocol(env)
case "req.ip" if req.isDefined => req.get.theIpAddress(env)
case "req.ip_address" if req.isDefined => req.get.theIpAddress(env)
case "req.secured" if req.isDefined => req.get.theSecured(env).toString
case "req.version" if req.isDefined => req.get.version
case r"req.headers.$field@(.*):$defaultValue@(.*)" if req.isDefined =>
Expand Down
2 changes: 1 addition & 1 deletion otoroshi/app/next/plugins/api.scala
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ trait NgNamedPlugin extends NamedPlugin { self =>
def categories: Seq[NgPluginCategory]
def tags: Seq[String] = Seq.empty
def steps: Seq[NgStep]
def multiInstance: Boolean
def multiInstance: Boolean = true
def defaultConfigObject: Option[NgPluginConfig]
override final def defaultConfig: Option[JsObject] =
defaultConfigObject.map(_.json.asOpt[JsObject].getOrElse(Json.obj()))
Expand Down
123 changes: 122 additions & 1 deletion otoroshi/app/next/plugins/apikey.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ 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
import play.api.mvc.{Result, Results}

import scala.concurrent.duration.DurationInt
import scala.concurrent.{ExecutionContext, Future}
Expand Down Expand Up @@ -778,3 +779,123 @@ class ApikeyAuthModule extends NgPreRouting {
}
}
}

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))
}
}
}
}
}
217 changes: 217 additions & 0 deletions otoroshi/app/next/plugins/external.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package otoroshi.next.plugins

import com.github.blemale.scaffeine.{Cache, Scaffeine}
import otoroshi.el.GlobalExpressionLanguage
import otoroshi.env.Env
import otoroshi.gateway.Errors
import otoroshi.next.plugins.api._
import otoroshi.utils.syntax.implicits._
import play.api.Logger
import play.api.libs.json._
import play.api.mvc.Results

import java.util.concurrent.TimeUnit
import scala.concurrent.duration.{DurationInt, FiniteDuration}
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.util._

case class NgExternalValidatorConfig(
cacheExpression: Option[String] = None,
ttl: FiniteDuration = 60.seconds,
timeout: FiniteDuration = 30.seconds,
url: Option[String] = None,
headers: Map[String, String] = Map.empty,
errorMessage: String = "forbidden",
errorStatus: Int = 403,
) extends NgPluginConfig {
def json: JsValue = NgExternalValidatorConfig.format.writes(this)
}

object NgExternalValidatorConfig {
val format = new Format[NgExternalValidatorConfig] {
override def writes(o: NgExternalValidatorConfig): JsValue = Json.obj(
"cache_expression" -> o.cacheExpression.map(_.json).getOrElse(JsNull).asValue,
"url" -> o.url.map(_.json).getOrElse(JsNull).asValue,
"ttl" -> o.ttl.toMillis,
"timeout" -> o.timeout.toMillis,
"headers" -> o.headers,
"error_message" -> o.errorMessage,
"error_status" -> o.errorStatus,
)
override def reads(json: JsValue): JsResult[NgExternalValidatorConfig] = Try {
NgExternalValidatorConfig(
cacheExpression = json.select("cache_expression").asOpt[String].filterNot(_.isBlank),
url = json.select("url").asOpt[String].filterNot(_.isBlank),
ttl = json.select("ttl").asOpt[Long].map(v => FiniteDuration(v, TimeUnit.MILLISECONDS)).getOrElse(60.seconds),
timeout = json.select("timeout").asOpt[Long].map(v => FiniteDuration(v, TimeUnit.MILLISECONDS)).getOrElse(30.seconds),
errorStatus = json.select("error_status").asOpt[Int].getOrElse(403),
errorMessage = json.select("error_message").asOpt[String].getOrElse("forbidden"),
headers = json.select("headers").asOpt[Map[String, String]].getOrElse(Map.empty),
)
} match {
case Failure(e) => JsError(e.getMessage)
case Success(s) => JsSuccess(s)
}
}
}

class NgExternalValidator extends NgAccessValidator {

override def name: String = "External request validator"
override def visibility: NgPluginVisibility = NgPluginVisibility.NgUserLand
override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.AccessControl)
override def steps: Seq[NgStep] = Seq(NgStep.ValidateAccess)
override def defaultConfigObject: Option[NgPluginConfig] = NgExternalValidatorConfig().some
override def description: Option[String] =
"This plugin checks let requests pass based on an external validation service".some

private val logger = Logger("otoroshi-plugins-external-validator")
private val cache: Cache[String, (FiniteDuration, Promise[Boolean])] = Scaffeine()
.expireAfter[String, (FiniteDuration, Promise[Boolean])](
create = (key, value) => value._1,
update = (key, value, currentDuration) => currentDuration,
read = (key, value, currentDuration) => currentDuration,
)
.maximumSize(10000)
.build()

private def externalValidation(ctx: NgAccessContext, rawUrl: String, config: NgExternalValidatorConfig, cacheKey: Option[String])(implicit env: Env, ec: ExecutionContext): Future[NgAccess] = cache.synchronized {
val promise = Promise[Boolean]()
cacheKey.foreach(key => cache.put(key, (config.ttl, promise)))
val url = GlobalExpressionLanguage.apply(
value = rawUrl,
req = ctx.request.some,
service = None,
route = ctx.route.some,
apiKey = ctx.apikey,
user = ctx.user,
context = Map.empty,
attrs = ctx.attrs,
env = env
)
val headers = config.headers.mapValues(v => GlobalExpressionLanguage.apply(
value = v,
req = ctx.request.some,
service = None,
route = ctx.route.some,
apiKey = ctx.apikey,
user = ctx.user,
context = Map.empty,
attrs = ctx.attrs,
env = env
))
env.Ws.url(url)
.withRequestTimeout(config.timeout)
.withFollowRedirects(true)
.withHttpHeaders(headers.toSeq: _*)
.post(ctx.wasmJson)
.flatMap { resp =>
if (resp.status == 200) {
if (resp.json.select("pass").asOpt[Boolean].getOrElse(false)) {
cacheKey.foreach(key => cache.getIfPresent(key).foreach(t => t._2.trySuccess(true)))
NgAccess.NgAllowed.vfuture
} else {
cacheKey.foreach(key => cache.getIfPresent(key).foreach(t => t._2.trySuccess(false)))
Errors
.craftResponseResult(
config.errorMessage,
Results.Status(config.errorStatus),
ctx.request,
None,
Some("errors.failed.external.validation.pass"),
duration = ctx.report.getDurationNow(),
overhead = ctx.report.getOverheadInNow(),
attrs = ctx.attrs,
maybeRoute = ctx.route.some
)
.map(r => NgAccess.NgDenied(r))
}
} else {
cacheKey.foreach(key => cache.getIfPresent(key).foreach(t => t._2.trySuccess(false)))
Errors
.craftResponseResult(
config.errorMessage,
Results.Status(config.errorStatus),
ctx.request,
None,
Some("errors.failed.external.validation.status"),
duration = ctx.report.getDurationNow(),
overhead = ctx.report.getOverheadInNow(),
attrs = ctx.attrs,
maybeRoute = ctx.route.some
)
.map(r => NgAccess.NgDenied(r))
}
}
.recoverWith {
case ex => {
logger.error(s"error while validating request with external service at '${url}'", ex)
cacheKey.foreach { key =>
cache.getIfPresent(key).foreach(t => t._2.trySuccess(false))
cache.invalidate(key)
}
Errors
.craftResponseResult(
config.errorMessage,
Results.Status(config.errorStatus),
ctx.request,
None,
Some("errors.failed.external.validation.error"),
duration = ctx.report.getDurationNow(),
overhead = ctx.report.getOverheadInNow(),
attrs = ctx.attrs,
maybeRoute = ctx.route.some
)
.map(r => NgAccess.NgDenied(r))
}
}
}

override def access(ctx: NgAccessContext)(implicit env: Env, ec: ExecutionContext): Future[NgAccess] = {
val config = ctx
.cachedConfig(internalName)(NgExternalValidatorConfig.format)
.getOrElse(NgExternalValidatorConfig())
config.url match {
case None => NgAccess.NgAllowed.vfuture
case Some(rawUrl) => {
config.cacheExpression match {
case None => externalValidation(ctx, rawUrl, config, None)
case Some(cacheExpressionRaw) => {
val cacheKey = GlobalExpressionLanguage.apply(
value = cacheExpressionRaw,
req = ctx.request.some,
service = None,
route = ctx.route.some,
apiKey = ctx.apikey,
user = ctx.user,
context = Map.empty,
attrs = ctx.attrs,
env = env
)
cache.getIfPresent(cacheKey) match {
case None => externalValidation(ctx, rawUrl, config, cacheKey.some)
case Some(tuple) => tuple._2.future.flatMap {
case true => NgAccess.NgAllowed.vfuture
case false => {
Errors
.craftResponseResult(
config.errorMessage,
Results.Status(config.errorStatus),
ctx.request,
None,
Some("errors.failed.external.validation"),
duration = ctx.report.getDurationNow(),
overhead = ctx.report.getOverheadInNow(),
attrs = ctx.attrs,
maybeRoute = ctx.route.some
)
.map(r => NgAccess.NgDenied(r))
}
}
}
}
}
}
}
}
}
Loading

0 comments on commit ce92ca2

Please sign in to comment.