├── .gitignore ├── src ├── test │ ├── resources │ │ ├── not-a-json-at-all.json │ │ ├── access-token-not-valid.json │ │ ├── security_custom-security.conf │ │ ├── security_rules.conf │ │ ├── security_filter.conf │ │ ├── valid-token-with-uid-scope.json │ │ ├── token-with-missed-token-type.json │ │ ├── security_no-scopes.conf │ │ ├── valid-token-with-unknown-field.json │ │ ├── security_unknown-http-method.conf │ │ ├── security_commented.conf │ │ └── security_pass-through.conf │ └── scala │ │ └── org │ │ └── zalando │ │ └── zhewbacca │ │ ├── TestingFilters.scala │ │ ├── AlwaysPassAuthProviderSpec.scala │ │ ├── TokenInfoConverterSpec.scala │ │ ├── ScopeTestSpec.scala │ │ ├── OAuth2TokenSpec.scala │ │ ├── SecurityFilterSpec.scala │ │ ├── RequestValidatorSpec.scala │ │ ├── OAuth2AuthProviderSpec.scala │ │ ├── SecurityRulesRepositorySpec.scala │ │ ├── SecurityRuleSpec.scala │ │ └── IAMClientSpec.scala └── main │ └── scala │ └── org │ └── zalando │ └── zhewbacca │ ├── AuthProvider.scala │ ├── metrics │ ├── PlugableMetrics.scala │ └── NoOpPlugableMetrics.scala │ ├── AuthResult.scala │ ├── AlwaysPassAuthProvider.scala │ ├── Scope.scala │ ├── TokenInfo.scala │ ├── RequestValidator.scala │ ├── OAuth2Token.scala │ ├── SecurityFilter.scala │ ├── TokenInfoConverter.scala │ ├── OAuth2AuthProvider.scala │ ├── SecurityRule.scala │ ├── IAMClient.scala │ └── SecurityRulesRepository.scala ├── MAINTAINERS ├── .travis.yml ├── project └── plugins.sbt ├── LICENSE ├── scalastyle-config.xml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | logs/ 3 | 4 | -------------------------------------------------------------------------------- /src/test/resources/not-a-json-at-all.json: -------------------------------------------------------------------------------- 1 | { not-a-json-at-all } -------------------------------------------------------------------------------- /MAINTAINERS: -------------------------------------------------------------------------------- 1 | Dmitry Krivaltsevich 2 | William Okuyama 3 | -------------------------------------------------------------------------------- /src/test/resources/access-token-not-valid.json: -------------------------------------------------------------------------------- 1 | {"error": "invalid_request","error_description":"Access Token not valid"} -------------------------------------------------------------------------------- /src/test/resources/security_custom-security.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | { 3 | method: POST 4 | pathRegex: "/bar.*" 5 | scopes: ["uid"] 6 | } 7 | ] -------------------------------------------------------------------------------- /src/test/resources/security_rules.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | { 3 | method: GET 4 | pathRegex: /foo 5 | scopes: ["uid", "entity.read"] 6 | } 7 | ] -------------------------------------------------------------------------------- /src/test/resources/security_filter.conf: -------------------------------------------------------------------------------- 1 | # security rules for security filter 2 | rules = [ 3 | { 4 | method: GET 5 | pathRegex: / 6 | scopes: ["uid"] 7 | } 8 | ] -------------------------------------------------------------------------------- /src/test/resources/valid-token-with-uid-scope.json: -------------------------------------------------------------------------------- 1 | { 2 | "scope": [ 3 | "uid" 4 | ], 5 | "token_type": "Bearer", 6 | "access_token": "311f3ab2-4116-45a0-8bb0-50c3bca0441d", 7 | "uid": "user uid" 8 | } -------------------------------------------------------------------------------- /src/test/resources/token-with-missed-token-type.json: -------------------------------------------------------------------------------- 1 | { 2 | "scope": [ 3 | "uid" 4 | ], 5 | // "token_type" was missed 6 | "access_token": "311f3ab2-4116-45a0-8bb0-50c3bca0441d", 7 | "uid": "user uid" 8 | } -------------------------------------------------------------------------------- /src/test/resources/security_no-scopes.conf: -------------------------------------------------------------------------------- 1 | # this is invalid configuration file for security rules 2 | # scopes should be defined for every rule 3 | rules = [ 4 | { 5 | method: GET 6 | pathRegex: /api 7 | } 8 | ] -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/AuthProvider.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import scala.concurrent.Future 4 | 5 | trait AuthProvider { 6 | def valid(token: Option[OAuth2Token], scope: Scope): Future[AuthResult] 7 | } 8 | -------------------------------------------------------------------------------- /src/test/resources/valid-token-with-unknown-field.json: -------------------------------------------------------------------------------- 1 | { 2 | "scope": [ 3 | "uid" 4 | ], 5 | "token_type": "Bearer", 6 | "access_token": "311f3ab2-4116-45a0-8bb0-50c3bca0441d", 7 | "uid": "user uid", 8 | "new_and_shiny_field": true 9 | } -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/metrics/PlugableMetrics.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca.metrics 2 | 3 | import scala.concurrent.Future 4 | 5 | trait PlugableMetrics { 6 | def timing[A](a: Future[A]): Future[A] 7 | def gauge[A](f: => A): Unit 8 | } 9 | -------------------------------------------------------------------------------- /src/test/resources/security_unknown-http-method.conf: -------------------------------------------------------------------------------- 1 | # this is invalid configuration file for security rules 2 | # error is in HTTP method name: 'POZT' should be 'POST' 3 | rules = [ 4 | { 5 | method: POZT 6 | pathRegex: /foo 7 | scopes: ["scope1"] 8 | } 9 | ] -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | 3 | scala: 4 | - 2.11.7 5 | 6 | jdk: 7 | - oraclejdk8 8 | 9 | script: sbt coverage test 10 | 11 | # go faster on travis 12 | sudo: false 13 | 14 | notifications: 15 | email: 16 | - cezary.lada.extern@zalando.de 17 | 18 | -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/AuthResult.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | sealed abstract class AuthResult 4 | 5 | case object AuthTokenInvalid extends AuthResult 6 | case object AuthTokenEmpty extends AuthResult 7 | case class AuthTokenValid(tokenInfo: TokenInfo) extends AuthResult 8 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // style plugins 2 | addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.5.1") 3 | addSbtPlugin("org.scalastyle" % "scalastyle-sbt-plugin" % "0.7.0") 4 | 5 | // code coverage 6 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.3.3") 7 | 8 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") 9 | -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/metrics/NoOpPlugableMetrics.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca.metrics 2 | 3 | import scala.concurrent.Future 4 | 5 | class NoOpPlugableMetrics extends PlugableMetrics { 6 | override def timing[A](a: Future[A]): Future[A] = a 7 | 8 | override def gauge[A](f: => A): Unit = () 9 | } 10 | -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/AlwaysPassAuthProvider.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import scala.concurrent.Future 4 | 5 | class AlwaysPassAuthProvider(tokenInfo: TokenInfo) extends AuthProvider { 6 | override def valid(token: Option[OAuth2Token], scope: Scope): Future[AuthResult] = 7 | Future.successful(AuthTokenValid(tokenInfo)) 8 | } 9 | -------------------------------------------------------------------------------- /src/test/scala/org/zalando/zhewbacca/TestingFilters.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import javax.inject.Inject 4 | 5 | import play.api.http.HttpFilters 6 | import play.api.mvc.EssentialFilter 7 | 8 | class TestingFilters @Inject() (securityFilter: SecurityFilter) extends HttpFilters { 9 | val filters: Seq[EssentialFilter] = Seq(securityFilter) 10 | } 11 | -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/Scope.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | case class Scope private[zhewbacca] (names: Set[String]) { 4 | 5 | private val nonEmptyNames = names.filterNot(_.trim.isEmpty) 6 | 7 | def in(that: Scope): Boolean = { 8 | nonEmptyNames.intersect(that.names) == nonEmptyNames 9 | } 10 | } 11 | 12 | object Scope { 13 | val Default = Scope(Set("uid")) 14 | val Empty = Scope(Set("")) 15 | } 16 | -------------------------------------------------------------------------------- /src/test/resources/security_commented.conf: -------------------------------------------------------------------------------- 1 | # This is an example of valid configuration file with comments inside it. 2 | # Commented lines don't influence on performance of security module. 3 | rules = [ 4 | { 5 | method: OPTIONS 6 | pathRegex: / 7 | scopes: ["app.resource.read"] 8 | } 9 | # You can mix security rules definitions and comments. 10 | { 11 | method: PUT // Inline comments are supported. 12 | pathRegex: / 13 | scopes: ["app.resource.write"] # This is also inline comment 14 | } 15 | ] -------------------------------------------------------------------------------- /src/test/resources/security_pass-through.conf: -------------------------------------------------------------------------------- 1 | # This configuration file demonstrates how users can explicitly allow to 'pass-through' request 2 | # for given URI. In combination with restrictive 'catch all' rules it can help to build 3 | # 'whitelists'. 4 | # 5 | # In given example only "/foo" endpoint is allowed to be non-protected (no OAuth2 tokens required). 6 | # 7 | 8 | rules = [ 9 | { 10 | method: GET 11 | pathRegex: /foo 12 | allowed: true 13 | } 14 | 15 | # 'catch all' rule 16 | { 17 | method: GET 18 | pathRegex: "/.*" 19 | allowed: false 20 | } 21 | ] -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/TokenInfo.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import play.api.libs.functional.syntax._ 4 | import play.api.libs.json.{JsPath, Reads} 5 | 6 | case class TokenInfo(accessToken: String, scope: Scope, tokenType: String, userUid: String) 7 | 8 | object TokenInfo { 9 | implicit val tokenInfoReads: Reads[TokenInfo] = ( 10 | (JsPath \ "access_token").read[String] and 11 | (JsPath \ "scope").read[Seq[String]].map(names => Scope(Set(names: _*))) and 12 | (JsPath \ "token_type").read[String] and 13 | (JsPath \ "uid").read[String] 14 | )(TokenInfo.apply _) 15 | } 16 | -------------------------------------------------------------------------------- /src/test/scala/org/zalando/zhewbacca/AlwaysPassAuthProviderSpec.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import org.specs2.concurrent.ExecutionEnv 4 | import org.specs2.mutable.Specification 5 | 6 | class AlwaysPassAuthProviderSpec extends Specification { 7 | val TestTokenInfo = TokenInfo("", Scope.Empty, "token type", "user uid") 8 | 9 | "'Always pass' Authorization Provider" should { 10 | "accept any tokens and scopes and treat them as valid" in { implicit ee: ExecutionEnv => 11 | val authProvider = new AlwaysPassAuthProvider(TestTokenInfo) 12 | val scope = Scope(Set("any_scope")) 13 | val token = Some(OAuth2Token("6afe9886-0a0a-4ace-8bc7-fb96920fb764")) 14 | 15 | authProvider.valid(token, scope) must beEqualTo(AuthTokenValid(TestTokenInfo)).await 16 | } 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/test/scala/org/zalando/zhewbacca/TokenInfoConverterSpec.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import org.specs2.mutable.Specification 4 | import play.api.test.FakeRequest 5 | 6 | class TokenInfoConverterSpec extends Specification { 7 | 8 | "TokenInfoConverter" should { 9 | "extract TokenInfo from request metadata" in { 10 | import org.zalando.zhewbacca.TokenInfoConverter._ 11 | 12 | val tokenInfo = TokenInfo("12345", Scope(Set("uid", "entity.write", "entity.read")), "Bearer", "test-user-uid") 13 | val request = FakeRequest().withTokenInfo(tokenInfo) 14 | 15 | request.tokenInfo must beEqualTo(tokenInfo) 16 | } 17 | 18 | "raise exception when TokenInfo not present in request metadata" in { 19 | import org.zalando.zhewbacca.TokenInfoConverter._ 20 | 21 | val request = FakeRequest() 22 | 23 | request.tokenInfo must throwA[RuntimeException]("access token not provided") 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/RequestValidator.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import play.api.Logger 4 | import play.api.libs.concurrent.Execution.Implicits._ 5 | import play.api.mvc._ 6 | 7 | import scala.concurrent.Future 8 | import scala.util.control.NonFatal 9 | 10 | private[zhewbacca] object RequestValidator { 11 | 12 | val logger: Logger = Logger(this.getClass) 13 | 14 | def validate[A](scope: Scope, requestHeader: RequestHeader, authProvider: AuthProvider): Future[Either[Result, TokenInfo]] = { 15 | authProvider.valid(OAuth2Token.from(requestHeader), scope).map { 16 | case AuthTokenValid(tokenInfo) => Right(tokenInfo) 17 | case AuthTokenInvalid => Left(Results.Forbidden) 18 | case AuthTokenEmpty => Left(Results.Unauthorized) 19 | } recover { 20 | case NonFatal(e) => 21 | logger.error(e.getMessage, e) 22 | logger.debug("Request forbidden because of failure in Authentication Provider") 23 | Left(Results.Forbidden) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/OAuth2Token.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import play.api.mvc.RequestHeader 4 | 5 | case class OAuth2Token private[zhewbacca] (private[zhewbacca] val value: String) { 6 | 7 | private val nonMaskedCharacters = 8 8 | 9 | /** 10 | * Token values are equivalent to passwords, so it's not allowed to show actual token values even in logs. 11 | * This methods keeps only first and last 4 characters of string representation of token value. 12 | * 13 | * @return token value which is safe to use in logs or to show to anyone 14 | */ 15 | def toSafeString: String = value.patch(nonMaskedCharacters / 2, "...", value.length - nonMaskedCharacters) 16 | } 17 | 18 | object OAuth2Token { 19 | 20 | private val TokenPattern = "Bearer ([a-zA-Z0-9-._~+/]+?)".r 21 | 22 | def from(from: RequestHeader): Option[OAuth2Token] = from.headers.get("Authorization").getOrElse("") match { 23 | case TokenPattern(accessToken) => Some(new OAuth2Token(accessToken)) 24 | case _ => None 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Zalando SE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/test/scala/org/zalando/zhewbacca/ScopeTestSpec.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import org.specs2.mutable.Specification 4 | 5 | class ScopeTestSpec extends Specification { 6 | 7 | "in method" should { 8 | 9 | "return 'true' if this scope is completely in given scope" in { 10 | Scope(Set("uid")).in(Scope(Set("uid"))) must beTrue 11 | Scope(Set("uid", "cn")).in(Scope(Set("uid", "cn"))) must beTrue 12 | Scope(Set("uid")).in(Scope(Set("uid", "cn"))) must beTrue 13 | Scope(Set("uid", "cn")).in(Scope(Set("cn", "uid"))) must beTrue 14 | } 15 | 16 | "return 'false' if this scope is partially in given scope" in { 17 | Scope(Set("uid", "cn")).in(Scope(Set("uid"))) must beFalse 18 | Scope(Set("uid", "cn")).in(Scope(Set("cn", "foo"))) must beFalse 19 | } 20 | 21 | "return 'false' if this scope is not in given scope" in { 22 | Scope(Set("uid")).in(Scope(Set("cn"))) must beFalse 23 | } 24 | } 25 | 26 | "empty scope" should { 27 | 28 | "be in any scope" in { 29 | Scope.Empty.in(Scope.Empty) must beTrue 30 | Scope.Empty.in(Scope(Set("uid"))) must beTrue 31 | Scope.Empty.in(Scope(Set("uid", "another_scope"))) must beTrue 32 | Scope(Set("uid", "")).in(Scope(Set("uid"))) must beTrue 33 | Scope(Set("uid", "", "")).in(Scope(Set("uid", ""))) must beTrue 34 | } 35 | 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/SecurityFilter.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import javax.inject.Inject 4 | 5 | import akka.stream.Materializer 6 | import play.api.Logger 7 | import play.api.mvc.{Filter, RequestHeader, Result} 8 | 9 | import scala.concurrent.{ExecutionContext, Future} 10 | 11 | /** 12 | * `SecurityFilter` intercepts every request and validates it against security rules. 13 | * 14 | * It forwards an original request to the next filter in the chain if this request doesn't have corresponding 15 | * security rule. Authenticated requests will be modified to include `TokenInfo` information into request's metadata. 16 | * 17 | * @param rulesRepository security rules repository 18 | * @param mat materializer (required by Play framework) 19 | * @param ec an ExecutionContext for rules 20 | */ 21 | class SecurityFilter @Inject() ( 22 | rulesRepository: SecurityRulesRepository, 23 | implicit val mat: Materializer, 24 | implicit val ec: ExecutionContext 25 | ) extends Filter { 26 | 27 | override def apply(nextFilter: (RequestHeader) => Future[Result])(requestHeader: RequestHeader): Future[Result] = { 28 | rulesRepository.get(requestHeader).getOrElse { 29 | Logger.debug(s"No security rules found for ${requestHeader.method} ${requestHeader.uri}. Access denied.") 30 | new DenyAllRule 31 | }.execute(nextFilter, requestHeader) 32 | } 33 | 34 | } 35 | 36 | -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/TokenInfoConverter.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import play.api.mvc.RequestHeader 4 | 5 | object TokenInfoConverter { 6 | 7 | implicit class AuthenticatedRequestHeader(underlying: RequestHeader) { 8 | 9 | private val AccessTokenKey = "tokenInfo.access_token" 10 | private val ScopeKey = "tokenInfo.scope" 11 | private val ScopeSeparator = '|' 12 | private val TokenTypeKey = "tokenInfo.token_type" 13 | private val UidKey = "tokenInfo.uid" 14 | 15 | def tokenInfo: TokenInfo = { 16 | val accessToken = underlying.tags.getOrElse(AccessTokenKey, sys.error("access token not provided")) 17 | val scopeNames = underlying.tags.getOrElse(ScopeKey, sys.error("scope not provided")) 18 | .split(ScopeSeparator) 19 | .toSet 20 | val tokenType = underlying.tags.getOrElse(TokenTypeKey, sys.error("token type is not provided")) 21 | val uid = underlying.tags.getOrElse(UidKey, sys.error("user id is not provided")) 22 | 23 | TokenInfo(accessToken, Scope(scopeNames), tokenType, uid) 24 | } 25 | 26 | private[zhewbacca] def withTokenInfo(tokenInfo: TokenInfo): RequestHeader = { 27 | underlying 28 | .withTag(AccessTokenKey, tokenInfo.accessToken) 29 | .withTag(ScopeKey, tokenInfo.scope.names.mkString(ScopeSeparator.toString)) 30 | .withTag(TokenTypeKey, tokenInfo.tokenType) 31 | .withTag(UidKey, tokenInfo.userUid) 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/OAuth2AuthProvider.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import javax.inject.{Inject, Singleton} 4 | 5 | import play.api.Logger 6 | import play.api.libs.concurrent.Execution.Implicits._ 7 | 8 | import scala.concurrent.Future 9 | 10 | /** 11 | * Authorization provider which uses Zalando's IAM API to verify given OAuth2 token. 12 | */ 13 | @Singleton 14 | class OAuth2AuthProvider @Inject() (getTokenInfo: (OAuth2Token) => Future[Option[TokenInfo]]) 15 | extends AuthProvider { 16 | 17 | val logger: Logger = Logger("security.OAuth2AuthProvider") 18 | 19 | private val bearerTokenType = "Bearer" 20 | 21 | override def valid(token: Option[OAuth2Token], scope: Scope): Future[AuthResult] = 22 | token.map(validateToken(_, scope)).getOrElse(Future.successful(AuthTokenEmpty)) 23 | 24 | private def validateToken(token: OAuth2Token, scope: Scope): Future[AuthResult] = 25 | getTokenInfo(token).map(tokenInfoOpt => 26 | tokenInfoOpt.map(validateTokenInfo(_, token, scope)).getOrElse(invalid(token))) 27 | 28 | private def validateTokenInfo(tokenInfo: TokenInfo, token: OAuth2Token, scope: Scope): AuthResult = { 29 | tokenInfo match { 30 | case TokenInfo(`token`.value, thatScope, `bearerTokenType`, _) if scope.in(thatScope) => AuthTokenValid(tokenInfo) 31 | case _ => 32 | logger.info(s"Token '${token.toSafeString} has insufficient scope or wrong type.'") 33 | invalid(token) 34 | } 35 | } 36 | 37 | private def invalid(token: OAuth2Token): AuthResult = { 38 | logger.debug(s"Token '${token.toSafeString} is not valid'") 39 | AuthTokenInvalid 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test/scala/org/zalando/zhewbacca/OAuth2TokenSpec.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import org.specs2.mutable._ 4 | import play.api.test.FakeRequest 5 | 6 | class OAuth2TokenSpec extends Specification { 7 | 8 | "OAuth2 token" should { 9 | 10 | "be extracted from 'Authorization' request header" in { 11 | val request = FakeRequest().withHeaders("Authorization" -> "Bearer 267534eb-3135-4b64-9bab-9573300a0634") 12 | OAuth2Token.from(request) must be equalTo Some(OAuth2Token("267534eb-3135-4b64-9bab-9573300a0634")) 13 | } 14 | 15 | "be extracted only from first 'Authorization' request header" in { 16 | val request = FakeRequest().withHeaders( 17 | "Authorization" -> "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==", 18 | "Authorization" -> "Bearer 78d6c4c9-b777-4524-9c59-dbf55a3f8ad1" 19 | ) 20 | 21 | OAuth2Token.from(request) must be equalTo None 22 | } 23 | 24 | "be empty for request without 'Authorization' header in it" in { 25 | val request = FakeRequest().withHeaders("Content-type" -> "application/json") 26 | OAuth2Token.from(request) must be equalTo None 27 | } 28 | 29 | "be empty for request without headers" in { 30 | val request = FakeRequest() 31 | OAuth2Token.from(request) must be equalTo None 32 | } 33 | 34 | "be empty for request with other type of authorization" in { 35 | val request = FakeRequest().withHeaders("Authorization" -> "Token 0675b082ebea09b4484b053285495458") 36 | OAuth2Token.from(request) must be equalTo None 37 | } 38 | 39 | } 40 | 41 | "toSafeString method" should { 42 | 43 | "mask token value except first and last 4 characters" in { 44 | val request = FakeRequest().withHeaders("Authorization" -> "Bearer dbc1ec97-d01a-4b10-b853-ec7dedeff8d9") 45 | OAuth2Token.from(request).get.toSafeString must be equalTo "dbc1...f8d9" 46 | } 47 | 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/test/scala/org/zalando/zhewbacca/SecurityFilterSpec.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import play.api.inject._ 4 | import play.api.inject.guice.GuiceApplicationBuilder 5 | import play.api.mvc.Results._ 6 | import play.api.mvc._ 7 | import play.api.test.{FakeRequest, PlaySpecification} 8 | import play.api.{Application, Mode} 9 | 10 | class SecurityFilterSpec extends PlaySpecification with BodyParsers { 11 | 12 | val TestTokenInfo = TokenInfo("", Scope.Empty, "token type", "user uid") 13 | 14 | val routes: PartialFunction[(String, String), Handler] = { 15 | // test action returning action type. Shows the usage and makes it possible to test basic behaviour 16 | // security rules described in 'security_filter.conf' file 17 | case ("GET", "/") => Action { request => 18 | import TokenInfoConverter._ 19 | Ok(request.tokenInfo.tokenType) 20 | } 21 | 22 | case ("GET", "/unprotected") => Action { 23 | Ok 24 | } 25 | } 26 | 27 | def appWithRoutes: Application = new GuiceApplicationBuilder() 28 | .in(Mode.Test) 29 | .bindings(bind[AuthProvider] to new AlwaysPassAuthProvider(TestTokenInfo)) 30 | .routes(routes) 31 | .configure( 32 | "play.http.filters" -> "org.zalando.zhewbacca.TestingFilters", 33 | "authorisation.rules.file" -> "security_filter.conf" 34 | ) 35 | .build 36 | 37 | "SecurityFilter" should { 38 | 39 | "allow protected inner action to access token info" in { 40 | val response = route(appWithRoutes, FakeRequest(GET, "/")).get 41 | status(response) must beEqualTo(OK) 42 | contentAsString(response) must beEqualTo(TestTokenInfo.tokenType) 43 | } 44 | 45 | "deny an access when there is no security rule for the reguest is given" in { 46 | val response = route(appWithRoutes, FakeRequest(GET, "/unprotected-by-mistake")).get 47 | status(response) must beEqualTo(FORBIDDEN) 48 | } 49 | 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /src/test/scala/org/zalando/zhewbacca/RequestValidatorSpec.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import org.specs2.mutable.Specification 4 | import play.api.mvc._ 5 | import play.api.test.FakeRequest 6 | 7 | import scala.concurrent.duration._ 8 | import scala.concurrent.{Await, Future} 9 | 10 | class RequestValidatorSpec extends Specification { 11 | 12 | val TestTokenInfo = TokenInfo("", Scope.Empty, "token type", "user uid") 13 | 14 | "Request Validator" should { 15 | "provide token information when token is valid" in { 16 | val authProvider = new AuthProvider { 17 | override def valid(token: Option[OAuth2Token], scope: Scope): Future[AuthResult] = 18 | Future.successful(AuthTokenValid(TestTokenInfo)) 19 | } 20 | 21 | val result = Await.result(RequestValidator.validate(Scope(Set("uid")), FakeRequest(), authProvider), 1.seconds) 22 | result.isRight must beTrue 23 | result.right.get must beEqualTo(TestTokenInfo) 24 | } 25 | 26 | "return HTTP status 401 (unauthorized) when token was not provided" in { 27 | val authProvider = new AuthProvider { 28 | override def valid(token: Option[OAuth2Token], scope: Scope): Future[AuthResult] = 29 | Future.successful(AuthTokenEmpty) 30 | } 31 | 32 | val result = Await.result(RequestValidator.validate(Scope(Set("uid")), FakeRequest(), authProvider), 1.seconds) 33 | result.isLeft must beTrue 34 | result.left.get must beEqualTo(Results.Unauthorized) 35 | } 36 | 37 | "return HTTP status 403 (forbidden) when token is in valid" in { 38 | val authProvider = new AuthProvider { 39 | override def valid(token: Option[OAuth2Token], scope: Scope): Future[AuthResult] = 40 | Future.successful(AuthTokenInvalid) 41 | } 42 | 43 | val result = Await.result(RequestValidator.validate(Scope(Set("uid")), FakeRequest(), authProvider), 1.seconds) 44 | result.isLeft must beTrue 45 | result.left.get must beEqualTo(Results.Forbidden) 46 | } 47 | 48 | "return HTTP status 403 (forbidden) when Authorization provider has failed" in { 49 | val authProvider = new AuthProvider { 50 | override def valid(token: Option[OAuth2Token], scope: Scope): Future[AuthResult] = 51 | Future.failed(new RuntimeException) 52 | } 53 | 54 | val result = Await.result(RequestValidator.validate(Scope(Set("uid")), FakeRequest(), authProvider), 1.seconds) 55 | result.isLeft must beTrue 56 | result.left.get must beEqualTo(Results.Forbidden) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/SecurityRule.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import org.zalando.zhewbacca.TokenInfoConverter._ 4 | import play.api.Logger 5 | import play.api.mvc.{RequestHeader, Result, Results} 6 | 7 | import scala.concurrent.{ExecutionContext, Future} 8 | 9 | trait SecurityRule { 10 | def isApplicableTo(requestHeader: RequestHeader): Boolean 11 | def execute(nextFilter: (RequestHeader) => Future[Result], requestHeader: RequestHeader)(implicit ec: ExecutionContext): Future[Result] 12 | } 13 | 14 | abstract class StrictRule(method: String, pathRegex: String) extends SecurityRule { 15 | 16 | private val RequestMatcherRegex = s"^$method $pathRegex$$".r 17 | 18 | def isApplicableTo(requestHeader: RequestHeader): Boolean = 19 | RequestMatcherRegex.pattern.matcher(s"${requestHeader.method} ${requestHeader.uri}").matches 20 | 21 | } 22 | 23 | abstract case class ValidateTokenRule( 24 | method: String, 25 | pathRegex: String, 26 | scope: Scope 27 | ) extends StrictRule(method, pathRegex) { 28 | 29 | def authProvider: AuthProvider 30 | 31 | override def execute(nextFilter: (RequestHeader) => Future[Result], requestHeader: RequestHeader)(implicit ec: ExecutionContext): Future[Result] = 32 | RequestValidator.validate(scope, requestHeader, authProvider).flatMap[Result] { 33 | case Right(tokenInfo) => 34 | Logger.info(s"Request #${requestHeader.id} authenticated as: ${tokenInfo.userUid}") 35 | nextFilter(requestHeader.withTokenInfo(tokenInfo)) 36 | 37 | case Left(result) => 38 | Logger.info(s"Request #${requestHeader.id} failed auth") 39 | Future.successful(result) 40 | } 41 | } 42 | 43 | /** 44 | * Allowed to 'pass-through' of any request. It means that no security checks will be applied. 45 | * It is often useful in combination with 'catch all' rule which forces to verify tokens for all endpoints. 46 | */ 47 | case class ExplicitlyAllowedRule(method: String, pathRegex: String) extends StrictRule(method, pathRegex) { 48 | 49 | override def execute(nextFilter: (RequestHeader) => Future[Result], requestHeader: RequestHeader)(implicit ec: ExecutionContext): Future[Result] = 50 | nextFilter(requestHeader) 51 | 52 | } 53 | 54 | /** 55 | * Useful for explicitly denied HTTP methods or URIs. 56 | */ 57 | case class ExplicitlyDeniedRule(method: String, pathRegex: String) extends StrictRule(method, pathRegex) { 58 | 59 | override def execute(nextFilter: (RequestHeader) => Future[Result], requestHeader: RequestHeader)(implicit ec: ExecutionContext): Future[Result] = 60 | Future.successful(Results.Forbidden) 61 | } 62 | 63 | /** 64 | * Default rule for `SecurityFilter`. 65 | */ 66 | class DenyAllRule extends SecurityRule { 67 | 68 | override def isApplicableTo(requestHeader: RequestHeader): Boolean = true 69 | 70 | override def execute(nextFilter: (RequestHeader) => Future[Result], requestHeader: RequestHeader)(implicit ec: ExecutionContext): Future[Result] = 71 | Future.successful(Results.Forbidden) 72 | } 73 | -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/IAMClient.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import java.util.concurrent.atomic.AtomicInteger 4 | import javax.inject.{Inject, Singleton} 5 | 6 | import akka.actor.ActorSystem 7 | import akka.pattern.CircuitBreaker 8 | import org.zalando.zhewbacca.metrics.PlugableMetrics 9 | import play.api.http.Status._ 10 | import play.api.libs.ws.WSClient 11 | import play.api.{Configuration, Logger} 12 | 13 | import scala.concurrent.duration._ 14 | import scala.concurrent.{ExecutionContext, Future} 15 | import scala.util.control.NonFatal 16 | 17 | /** 18 | * Retrieves TokenInfo for given OAuth2 token using IAM API. 19 | * 20 | * Class applies a Circuit Breaker pattern, so it must be a singleton in the client's code. Implementation 21 | * depends on Play infrastructure so it will work only in a context of running application. 22 | * 23 | * @param config Play config to get configuration parameters for WS client and circuit breaker 24 | */ 25 | @Singleton 26 | class IAMClient @Inject() ( 27 | config: Configuration, 28 | plugableMetrics: PlugableMetrics, 29 | ws: WSClient, 30 | actorSystem: ActorSystem, 31 | implicit val ec: ExecutionContext 32 | ) extends ((OAuth2Token) => Future[Option[TokenInfo]]) { 33 | 34 | val logger: Logger = Logger("security.IAMClient") 35 | 36 | val METRICS_BREAKER_CLOSED = 0 37 | val METRICS_BREAKER_OPEN = 1 38 | val circuitStatus = new AtomicInteger() 39 | 40 | plugableMetrics.gauge { 41 | circuitStatus.get 42 | } 43 | 44 | val authEndpoint = config.getString("authorisation.iam.endpoint").getOrElse( 45 | throw new IllegalArgumentException("Authorisation: IAM endpoint is not configured") 46 | ) 47 | 48 | val breakerMaxFailures = config.getInt("authorisation.iam.cb.maxFailures").getOrElse( 49 | throw new IllegalArgumentException("Authorisation: Circuit Breaker max failures is not configured") 50 | ) 51 | 52 | val breakerCallTimeout = config.getInt("authorisation.iam.cb.callTimeout").getOrElse( 53 | throw new IllegalArgumentException("Authorisation: Circuit Breaker call timeout is not configured") 54 | ).millis 55 | 56 | val breakerResetTimeout = config.getInt("authorisation.iam.cb.resetTimeout").getOrElse( 57 | throw new IllegalArgumentException("Authorisation: Circuit Breaker reset timeout is not configured") 58 | ).millis 59 | 60 | lazy val breaker: CircuitBreaker = new CircuitBreaker( 61 | actorSystem.scheduler, 62 | breakerMaxFailures, 63 | breakerCallTimeout, 64 | breakerResetTimeout 65 | ).onHalfOpen { 66 | circuitStatus.set(METRICS_BREAKER_OPEN) 67 | }.onOpen { 68 | circuitStatus.set(METRICS_BREAKER_OPEN) 69 | }.onClose { 70 | circuitStatus.set(METRICS_BREAKER_CLOSED) 71 | } 72 | 73 | override def apply(token: OAuth2Token): Future[Option[TokenInfo]] = { 74 | breaker.withCircuitBreaker( 75 | plugableMetrics.timing(ws.url(authEndpoint).withQueryString(("access_token", token.value)).get()) 76 | ).map { response => 77 | response.status match { 78 | case OK => Some(response.json.as[TokenInfo]) 79 | case _ => None 80 | } 81 | } recover { 82 | case NonFatal(e) => 83 | logger.error(s"Exception occurred during validation of token '${token.toSafeString}': $e") 84 | None // consider any exception as invalid token 85 | } 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/main/scala/org/zalando/zhewbacca/SecurityRulesRepository.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import javax.inject.Inject 4 | 5 | import com.typesafe.config.{Config, ConfigFactory} 6 | import play.api.mvc.RequestHeader 7 | import play.api.{Configuration, Logger} 8 | 9 | import scala.collection.JavaConversions._ 10 | import play.api.http.HttpVerbs._ 11 | 12 | class SecurityRulesRepository @Inject() (configuration: Configuration, provider: AuthProvider) { 13 | 14 | private val SupportedHttpMethods: Set[String] = Set(GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) 15 | 16 | private val ConfigKeyMethod = "method" 17 | private val ConfigKeyPathRegex = "pathRegex" 18 | private val ConfigKeyScopes = "scopes" 19 | private val ConfigKeyAllowed = "allowed" 20 | private val ConfigKeyRules = "rules" 21 | 22 | private val rules: Seq[StrictRule] = load() 23 | 24 | def get(requestHeader: RequestHeader): Option[StrictRule] = 25 | rules.find(_.isApplicableTo(requestHeader)) 26 | 27 | private def load(): Seq[StrictRule] = { 28 | val securityRulesFileName = configuration.getString("authorisation.rules.file").getOrElse("security_rules.conf") 29 | Logger.info(s"Configuration file for security rules: $securityRulesFileName") 30 | 31 | if (configFileExists(securityRulesFileName)) { 32 | ConfigFactory.load(securityRulesFileName) 33 | .getConfigList(ConfigKeyRules) 34 | .map(toRule) 35 | } else { 36 | sys.error(s"configuration file $securityRulesFileName for security rules not found") 37 | } 38 | } 39 | 40 | private def toRule(config: Config): StrictRule = { 41 | (getHttpMethod(config), config.getString(ConfigKeyPathRegex), getAllowedFlag(config), getScopeNames(config)) match { 42 | case (Some(method), pathRegex, Some(true), _) => 43 | Logger.info(s"Explicitly allowed unauthorized requests for method: '$method' and path regex: '$pathRegex'") 44 | new ExplicitlyAllowedRule(method, pathRegex) 45 | 46 | case (Some(method), pathRegex, Some(false), _) => 47 | Logger.info(s"Explicitly denied all requests for method: '$method' and path regex: '$pathRegex'") 48 | new ExplicitlyDeniedRule(method, pathRegex) 49 | 50 | case (Some(method), pathRegex, None, Some(scopeNames)) => 51 | Logger.info(s"Configured required scopes '$scopeNames' for method '$method' and path regex: '$pathRegex'") 52 | new ValidateTokenRule(method, pathRegex, Scope(scopeNames)) { 53 | override val authProvider: AuthProvider = provider 54 | } 55 | 56 | case _ => 57 | sys.error(s"Invalid config: $config") 58 | } 59 | } 60 | 61 | private def configFileExists(fileName: String): Boolean = 62 | Option(Thread.currentThread() 63 | .getContextClassLoader 64 | .getResource(fileName)) 65 | .isDefined 66 | 67 | private def getHttpMethod(config: Config): Option[String] = { 68 | if (SupportedHttpMethods(config.getString(ConfigKeyMethod))) { 69 | Some(config.getString(ConfigKeyMethod)) 70 | } else { 71 | None 72 | } 73 | } 74 | 75 | private def getAllowedFlag(config: Config): Option[Boolean] = { 76 | if (config.hasPath(ConfigKeyAllowed)) { 77 | Some(config.getBoolean(ConfigKeyAllowed)) 78 | } else { 79 | None 80 | } 81 | } 82 | 83 | private def getScopeNames(config: Config): Option[Set[String]] = { 84 | if (config.hasPath(ConfigKeyScopes)) { 85 | Some(config.getStringList(ConfigKeyScopes).toSet) 86 | } else { 87 | None 88 | } 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/test/scala/org/zalando/zhewbacca/OAuth2AuthProviderSpec.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import org.specs2.mutable.Specification 4 | 5 | import scala.concurrent.duration._ 6 | import scala.concurrent.{Await, Future} 7 | 8 | class OAuth2AuthProviderSpec extends Specification { 9 | 10 | "IAM Authorization Provider" should { 11 | 12 | "accept valid token with necessary scope" in { 13 | val tokenInfo = TokenInfo("311f3ab2-4116-45a0-8bb0-50c3bca0441d", Scope(Set("uid")), "Bearer", userUid = "1234") 14 | val request = new OAuth2AuthProvider((token: OAuth2Token) => Future.successful(Some(tokenInfo))).valid( 15 | Some(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")), 16 | Scope(Set("uid")) 17 | ) 18 | 19 | Await.result(request, 1.second) must beEqualTo(AuthTokenValid(tokenInfo)) 20 | } 21 | 22 | "accept token which has many scopes" in { 23 | val tokenInfo = TokenInfo("311f3ab2-4116-45a0-8bb0-50c3bca0441d", Scope(Set("uid", "cn")), "Bearer", userUid = "1234") 24 | val request = new OAuth2AuthProvider((token: OAuth2Token) => Future.successful(Some(tokenInfo))).valid( 25 | Some(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")), 26 | Scope(Set("uid")) 27 | ) 28 | 29 | Await.result(request, 1.second) must beEqualTo(AuthTokenValid(tokenInfo)) 30 | } 31 | 32 | "reject token when IAM reports it is not valid one" in { 33 | val request = new OAuth2AuthProvider((token: OAuth2Token) => Future.successful(None)).valid( 34 | Some(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")), 35 | Scope(Set("uid")) 36 | ) 37 | 38 | Await.result(request, 1.second) must beEqualTo(AuthTokenInvalid) 39 | } 40 | 41 | "reject token if IAM responses with Token Info for different token" in { 42 | val tokenInfo = TokenInfo("986c2946-c754-4e58-a0cb-7e86e3e9901b", Scope(Set("uid")), "Bearer", userUid = "1234") 43 | val request = new OAuth2AuthProvider((token: OAuth2Token) => Future.successful(Some(tokenInfo))).valid( 44 | Some(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")), 45 | Scope(Set("uid")) 46 | ) 47 | 48 | Await.result(request, 1.second) must beEqualTo(AuthTokenInvalid) 49 | } 50 | 51 | "reject token with insufficient scopes" in { 52 | val tokenInfo = TokenInfo("311f3ab2-4116-45a0-8bb0-50c3bca0441d", Scope(Set("uid")), "Bearer", userUid = "1234") 53 | val request = new OAuth2AuthProvider((token: OAuth2Token) => Future.successful(Some(tokenInfo))).valid( 54 | Some(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")), 55 | Scope(Set("uid", "seo_description.write")) 56 | ) 57 | 58 | Await.result(request, 1.second) must beEqualTo(AuthTokenInvalid) 59 | } 60 | 61 | "reject non 'Bearer' token" in { 62 | val tokenInfo = TokenInfo("311f3ab2-4116-45a0-8bb0-50c3bca0441d", Scope(Set("uid")), "Token", userUid = "1234") 63 | val request = new OAuth2AuthProvider((token: OAuth2Token) => Future.successful(Some(tokenInfo))).valid( 64 | Some(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")), 65 | Scope(Set("uid")) 66 | ) 67 | 68 | Await.result(request, 1.second) must beEqualTo(AuthTokenInvalid) 69 | } 70 | 71 | "reject empty token" in { 72 | val request = new OAuth2AuthProvider((token: OAuth2Token) => Future.successful(None)).valid( 73 | None, 74 | Scope(Set("uid")) 75 | ) 76 | 77 | Await.result(request, 1.second) must beEqualTo(AuthTokenEmpty) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/test/scala/org/zalando/zhewbacca/SecurityRulesRepositorySpec.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import org.specs2.mock.Mockito 4 | import org.specs2.mutable.Specification 5 | import play.api.Configuration 6 | import play.api.test.FakeRequest 7 | 8 | import scala.concurrent.ExecutionContext 9 | 10 | class SecurityRulesRepositorySpec extends Specification with Mockito { 11 | 12 | "SecurityRulesRepository" should { 13 | 14 | "load rules from default file" in { 15 | val provider = mock[AuthProvider] 16 | val repository = new SecurityRulesRepository(Configuration(), provider) 17 | val expectedRule = new ValidateTokenRule("GET", "/foo", Scope(Set("uid", "entity.read"))) { 18 | override val authProvider: AuthProvider = provider 19 | } 20 | 21 | repository.get(FakeRequest("GET", "/foo")) must beSome(expectedRule) 22 | } 23 | 24 | "load rules from custom file" in { 25 | val provider = mock[AuthProvider] 26 | val config = Configuration("authorisation.rules.file" -> "security_custom-security.conf") 27 | val repository = new SecurityRulesRepository(config, provider) 28 | val expectedRule = new ValidateTokenRule("POST", "/bar.*", Scope(Set("uid"))) { 29 | override val authProvider: AuthProvider = provider 30 | } 31 | 32 | repository.get(FakeRequest("POST", "/bar.*")) must beSome(expectedRule) 33 | } 34 | 35 | "raise an error when custom file is not available" in { 36 | val authProvider = mock[AuthProvider] 37 | val config = Configuration("authorisation.rules.file" -> "this-file-does-not-exist.conf") 38 | 39 | new SecurityRulesRepository(config, authProvider) must 40 | throwA[RuntimeException]("configuration file this-file-does-not-exist.conf for security rules not found") 41 | } 42 | 43 | "allow comments in security rules configuration file" in { 44 | val provider = mock[AuthProvider] 45 | val config = Configuration("authorisation.rules.file" -> "security_commented.conf") 46 | val repository = new SecurityRulesRepository(config, provider) 47 | val expectedRule = new ValidateTokenRule("OPTIONS", "/", Scope(Set("app.resource.read"))) { 48 | override val authProvider: AuthProvider = provider 49 | } 50 | 51 | repository.get(FakeRequest("OPTIONS", "/")) must beSome(expectedRule) 52 | } 53 | 54 | "raise an error when it cannot parse a configuration file" in { 55 | val authProvider = mock[AuthProvider] 56 | def config(fileName: String): Configuration = Configuration("authorisation.rules.file" -> fileName) 57 | 58 | new SecurityRulesRepository(config("security_unknown-http-method.conf"), authProvider) must throwA[RuntimeException] 59 | new SecurityRulesRepository(config("security_no-scopes.conf"), authProvider) must throwA[RuntimeException] 60 | } 61 | 62 | "return None if there is no configured rules for given request" in { 63 | val authProvider = mock[AuthProvider] 64 | val repository = new SecurityRulesRepository(Configuration(), authProvider) 65 | 66 | repository.get(FakeRequest("GET", "/unknown-uri")) must beNone 67 | } 68 | 69 | "allow explicitly to pass-through or deny a request for a specific URI" in { 70 | val authProvider = mock[AuthProvider] 71 | val configuration = Configuration("authorisation.rules.file" -> "security_pass-through.conf") 72 | val repository = new SecurityRulesRepository(configuration, authProvider) 73 | 74 | repository.get(FakeRequest("GET", "/foo")).get must beAnInstanceOf[ExplicitlyAllowedRule] 75 | repository.get(FakeRequest("GET", "/bar")).get must beAnInstanceOf[ExplicitlyDeniedRule] 76 | } 77 | 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /src/test/scala/org/zalando/zhewbacca/SecurityRuleSpec.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import org.specs2.mock.Mockito 4 | import org.specs2.mutable.Specification 5 | import play.api.mvc.{RequestHeader, Results} 6 | import play.api.test.FakeRequest 7 | import play.api.test.Helpers._ 8 | import play.api.http.Status.FORBIDDEN 9 | 10 | import scala.concurrent.{ExecutionContext, Future} 11 | 12 | class SecurityRuleSpec extends Specification with Mockito { 13 | sequential 14 | 15 | "ValidateTokenRule" should { 16 | "be applicable to specific route" in { 17 | val rule = new ValidateTokenRule("GET", "/api/.*", Scope.Default) { 18 | override val authProvider: AuthProvider = mock[AuthProvider] 19 | } 20 | 21 | rule.isApplicableTo(FakeRequest("GET", "/api/foo")) must beTrue 22 | rule.isApplicableTo(FakeRequest("GET", "/api/foo?a=b")) must beTrue 23 | rule.isApplicableTo(FakeRequest("GET", "/api/")) must beTrue 24 | 25 | rule.isApplicableTo(FakeRequest("GET", "/api")) must beFalse 26 | rule.isApplicableTo(FakeRequest("PUT", "/api/foo")) must beFalse 27 | rule.isApplicableTo(FakeRequest("GET", "/api2")) must beFalse 28 | } 29 | 30 | "inject TokenInfo into authenticated request" in { implicit ec: ExecutionContext => 31 | import TokenInfoConverter._ 32 | 33 | val request = FakeRequest("GET", "/") 34 | val rule = new ValidateTokenRule("GET", "/", Scope.Default) { 35 | override val authProvider: AuthProvider = { 36 | val tokenInfo = TokenInfo("", Scope.Default, "token-type", "test-user-id") 37 | val provider = mock[AuthProvider] 38 | provider.valid(any[Option[OAuth2Token]], any[Scope]) returns Future.successful(AuthTokenValid(tokenInfo)) 39 | } 40 | } 41 | val nextFilter = { request: RequestHeader => Future.successful(Results.Ok(request.tokenInfo.userUid)) } 42 | 43 | contentAsString(rule.execute(nextFilter, request)) must beEqualTo("test-user-id") 44 | } 45 | 46 | "return error for non-authenticated request" in { implicit ec: ExecutionContext => 47 | val request = FakeRequest("GET", "/") 48 | val rule = new ValidateTokenRule("GET", "/", Scope.Default) { 49 | override val authProvider: AuthProvider = { 50 | val provider = mock[AuthProvider] 51 | provider.valid(any[Option[OAuth2Token]], any[Scope]) returns Future.successful(AuthTokenInvalid) 52 | } 53 | } 54 | val nextFilter = { request: RequestHeader => Future.successful(Results.Ok) } 55 | 56 | status(rule.execute(nextFilter, request)) must beEqualTo(FORBIDDEN) 57 | } 58 | } 59 | 60 | "ExplicitlyAllowedRule" should { 61 | "pass unmodified request to next filter" in { implicit ec: ExecutionContext => 62 | val originalRequest = FakeRequest("GET", "/foo").withTag("testTag", "testValue") 63 | val rule = new ExplicitlyAllowedRule("GET", "/foo") 64 | val nextFilter = { request: RequestHeader => Future.successful(Results.Ok(request.tags("testTag"))) } 65 | 66 | contentAsString(rule.execute(nextFilter, originalRequest)) must beEqualTo("testValue") 67 | } 68 | 69 | "be applicable to specific request" in { 70 | val rule = new ExplicitlyAllowedRule("GET", "/foo.*") 71 | 72 | rule.isApplicableTo(FakeRequest("GET", "/foo/bar")) must beTrue 73 | rule.isApplicableTo(FakeRequest("GET", "/bar/foo")) must beFalse 74 | } 75 | } 76 | 77 | "ExplicitlyDeniedRule" should { 78 | 79 | "be applicable to specific request" in { 80 | val rule = new ExplicitlyDeniedRule("GET", "/foo.*") 81 | 82 | rule.isApplicableTo(FakeRequest("GET", "/foo/bar")) must beTrue 83 | rule.isApplicableTo(FakeRequest("GET", "/bar/foo")) must beFalse 84 | } 85 | 86 | "reject a request and respond with 403 HTTP status" in { implicit ec: ExecutionContext => 87 | val rule = new ExplicitlyDeniedRule("GET", "/foo") 88 | val nextFilter = { request: RequestHeader => Future.successful(Results.Ok) } 89 | 90 | status(rule.execute(nextFilter, FakeRequest())) must beEqualTo(FORBIDDEN) 91 | } 92 | 93 | } 94 | 95 | "DenyAllRule" should { 96 | 97 | "be applicable to all requests" in { 98 | val rule = new DenyAllRule 99 | 100 | rule.isApplicableTo(FakeRequest()) must beTrue 101 | } 102 | 103 | "reject a request and respond with 403 HTTP status" in { implicit ec: ExecutionContext => 104 | val rule = new DenyAllRule 105 | val nextFilter = { request: RequestHeader => Future.successful(Results.Ok) } 106 | 107 | status(rule.execute(nextFilter, FakeRequest())) must beEqualTo(FORBIDDEN) 108 | } 109 | 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /scalastyle-config.xml: -------------------------------------------------------------------------------- 1 | 2 | Scalastyle standard configuration 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | ((lazy)\s+(override|private|protected|final|implicit))|((implicit)\s+(override|private|protected|final))|((final)\s+(override|private|protected))|((private|protected)\s+(override)) 103 | 104 | Modifiers should be declared in the following order: "override access (private|protected) final implicit lazy". 105 | 106 | 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Play-security library 2 | 3 | Play! (v2.5) library to protect REST endpoint by OAuth2 token verification. In order to access to protected endpoint clients should pass an `Authorization` header with `Bearer` token in every request. 4 | This library implemented in non-blocking way and provides two implementation of token verification. 5 | 6 | - `AlwaysPassAuthProvider` implementation is useful for Development environment to bypass a security mechanism. This implementation assumes that every token is valid. 7 | - `OAuth2AuthProvider` implementation acquires token information from a 3rd-party endpoint and then verifies this info. Also it applies a [Circuit Breaker](http://doc.akka.io/docs/akka/snapshot/common/circuitbreaker.html) pattern. 8 | 9 | Clients of this library don't need to change their code in order to protect endpoints. All necessary security configurations are happening in the separate configuration file. 10 | 11 | ## Users guide 12 | 13 | Configure libraries dependencies in your `build.sbt`: 14 | 15 | ```scala 16 | libraryDependencies += "org.zalando" % "play-zhewbacca" % "0.1.0" 17 | ``` 18 | 19 | To configure Development environment: 20 | 21 | ```scala 22 | import com.google.inject.AbstractModule 23 | import org.zalando.zhewbacca._ 24 | import org.zalando.zhewbacca.metrics.{NoOpPlugableMetrics, PlugableMetrics} 25 | 26 | class DevModule extends AbstractModule { 27 | 28 | override def configure(): Unit = { 29 | bind(classOf[PlugableMetrics]).to(classOf[NoOpPlugableMetrics]) 30 | bind(classOf[AuthProvider]).to(classOf[AlwaysPassAuthProvider]) 31 | } 32 | 33 | } 34 | ``` 35 | 36 | For Production environment use: 37 | 38 | ```scala 39 | import com.google.inject.{ TypeLiteral, AbstractModule } 40 | import org.zalando.zhewbacca._ 41 | import org.zalando.zhewbacca.metrics.{NoOpPlugableMetrics, PlugableMetrics} 42 | 43 | import scala.concurrent.Future 44 | 45 | class ProdModule extends AbstractModule { 46 | 47 | override def configure(): Unit = { 48 | bind(classOf[AuthProvider]).to(classOf[OAuth2AuthProvider]) 49 | bind(classOf[PlugableMetrics]).to(classOf[NoOpPlugableMetrics]) 50 | bind(new TypeLiteral[(OAuth2Token) => Future[Option[TokenInfo]]]() {}).to(classOf[IAMClient]) 51 | } 52 | 53 | } 54 | ``` 55 | 56 | By default no metrics mechanism is used. User can implement ```PlugableMetrics``` to gather some simple metrics. 57 | See ```org.zalando.zhewbacca.IAMClient``` to learn what can be measured. 58 | 59 | 60 | You need to include `de.zalando.seo.play.security.SecurityFilter` into your applications' filters: 61 | 62 | ```scala 63 | package filters 64 | 65 | import javax.inject.Inject 66 | import org.zalando.zhewbacca.SecurityFilter 67 | import play.api.http.HttpFilters 68 | import play.api.mvc.EssentialFilter 69 | 70 | class MyFilters @Inject() (securityFilter: SecurityFilter) extends HttpFilters { 71 | val filters: Seq[EssentialFilter] = Seq(securityFilter) 72 | } 73 | ``` 74 | 75 | and then add `play.http.filters = filters.MyFilters` line to your `application.conf`. `SecurityFilter` rejects any requests 76 | to any endpoint which does not have a corresponding rule in the `security_rules.conf` file. 77 | 78 | Example of configuration in `application.conf` file: 79 | 80 | ``` 81 | # Full URL for authorization endpoint 82 | authorisation.iam.endpoint = "https://info.services.auth.zalando.com/oauth2/tokeninfo" 83 | 84 | # Maximum number of failures before opening the circuit 85 | authorisation.iam.cb.maxFailures = 4 86 | 87 | # Duration of time in milliseconds after which to consider a call a failure 88 | authorisation.iam.cb.callTimeout = 2000 89 | 90 | # Duration of time in milliseconds after which to attempt to close the circuit 91 | authorisation.iam.cb.resetTimeout = 60000 92 | 93 | # IAMClient depends on Play internal WS client so it also has to be configured. 94 | # The maximum time to wait when connecting to the remote host. 95 | # Play's default is 120 seconds 96 | play.ws.timeout.connection = 2000 97 | 98 | # The maximum time the request can stay idle when connetion is established but waiting for more data 99 | # Play's default is 120 seconds 100 | play.ws.timeout.idle = 2000 101 | 102 | # The total time you accept a request to take. It will be interrupted, whatever if the remote host is still sending data. 103 | # Play's default is none, to allow stream consuming. 104 | play.ws.timeout.request = 2000 105 | 106 | play.http.filters = filters.MyFilters 107 | 108 | play.modules.enabled += "modules.ProdModule" 109 | ``` 110 | 111 | By default this library reads security configuration from the `conf/security_rules.conf` file. 112 | You can change the file name by specifying a value for the key `authorisation.rules.file` in your `application.conf` file. 113 | 114 | ``` 115 | # This is an example of production-ready configuration security configuration. 116 | # You can copy it from here and paste right into your `conf/security_rules.conf` file. 117 | rules = [ 118 | # All GET requests to /api/my-resource has to have a valid OAuth2 token for scopes: uid, scop1, scope2, scope3 119 | { 120 | method: GET 121 | pathRegex: "/api/my-resource/.*" 122 | scopes: ["uid", "scope1", "scope2", "scope3"] 123 | } 124 | 125 | # POST requests to /api/my-resource require only scope2.write scope 126 | { 127 | method: POST 128 | pathRegex: "/api/my-resource/.*" 129 | scopes: ["scope2.write"] 130 | } 131 | 132 | # GET requests to /bar resources allowed to be without OAuth2 token 133 | { 134 | method: GET 135 | pathRegex: /bar 136 | allowed: true 137 | } 138 | 139 | # 'Catch All' rule will immidiately reject all requests for all other endpoints 140 | { 141 | method: GET 142 | pathRegex: "/.*" 143 | allowed: false // this is an example of inline comment 144 | } 145 | ] 146 | ``` 147 | 148 | Following example demonstrates how you can get an access to the Token Info object inside your controller: 149 | 150 | ```scala 151 | package controllers 152 | 153 | import javax.inject.Inject 154 | 155 | import de.zalando.seo.play.security.TokenInfoConverter._ 156 | import de.zalando.seo.play.security.TokenInfo 157 | 158 | class SeoDescriptionController @Inject() extends Controller { 159 | 160 | def create(uid: String): Action[AnyContent] = Action { request => 161 | val tokenInfo: TokenInfo = request.tokenInfo 162 | // do something with token info. For example, read user's UID: tokenInfo.userUid 163 | } 164 | } 165 | 166 | ``` 167 | -------------------------------------------------------------------------------- /src/test/scala/org/zalando/zhewbacca/IAMClientSpec.scala: -------------------------------------------------------------------------------- 1 | package org.zalando.zhewbacca 2 | 3 | import akka.actor.ActorSystem 4 | import org.specs2.mutable.Specification 5 | import org.zalando.zhewbacca.metrics.NoOpPlugableMetrics 6 | import play.api.http.Port 7 | import play.api.inject.guice.GuiceApplicationBuilder 8 | import play.api.libs.ws.WSClient 9 | import play.api.mvc.{Action, Handler, Result, Results} 10 | import play.api.test.WsTestClient 11 | import play.api.{Application, Configuration, Mode} 12 | import play.core.server.Server 13 | 14 | import scala.concurrent.duration._ 15 | import scala.concurrent.{Await, ExecutionContext} 16 | 17 | // @see https://www.playframework.com/documentation/2.5.x/ScalaTestingWebServiceClients 18 | class IAMClientSpec extends Specification { 19 | sequential 20 | 21 | "IAM Client" should { 22 | 23 | "parse response into TokenInfo" in { 24 | val app: Application = fakeApp(response = Results.Ok.sendResource("valid-token-with-uid-scope.json")) 25 | Server.withApplication(application = app) { port => 26 | WsTestClient.withClient { client => 27 | 28 | val request = iamClient(port, client, app.actorSystem) 29 | .apply(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")) 30 | 31 | Await.result(request, 5.second) must beEqualTo(Some(TokenInfo( 32 | "311f3ab2-4116-45a0-8bb0-50c3bca0441d", 33 | Scope(Set("uid")), 34 | "Bearer", 35 | userUid = "user uid" 36 | ))) 37 | 38 | } 39 | } 40 | } 41 | 42 | "skip all unknown fields in response" in { 43 | val app: Application = fakeApp(response = Results.Ok.sendResource("valid-token-with-unknown-field.json")) 44 | Server.withApplication(application = app) { port => 45 | WsTestClient.withClient { client => 46 | 47 | val request = iamClient(port, client, app.actorSystem) 48 | .apply(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")) 49 | 50 | Await.result(request, 5.second) must beEqualTo(Some(TokenInfo( 51 | "311f3ab2-4116-45a0-8bb0-50c3bca0441d", 52 | Scope(Set("uid")), 53 | "Bearer", 54 | userUid = "user uid" 55 | ))) 56 | 57 | } 58 | } 59 | } 60 | 61 | "should not return a Token Info when remote server responses that token is not valid" in { 62 | val app: Application = fakeApp(response = Results.Ok.sendResource("access-token-not-valid.json")) 63 | Server.withApplication(application = app) { port => 64 | WsTestClient.withClient { client => 65 | 66 | val request = iamClient(port, client, app.actorSystem) 67 | .apply(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")) 68 | 69 | Await.result(request, 5.second) must beEqualTo(None) 70 | 71 | } 72 | } 73 | } 74 | 75 | "should not return a Token Info when response cannot be parsed" in { 76 | val app: Application = fakeApp(response = Results.Ok.sendResource("access-token-not-valid.json")) 77 | Server.withApplication(application = app) { port => 78 | WsTestClient.withClient { client => 79 | 80 | val request = iamClient(port, client, app.actorSystem) 81 | .apply(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")) 82 | 83 | Await.result(request, 5.second) must beEqualTo(None) 84 | 85 | } 86 | } 87 | } 88 | 89 | "should not return a Token Info when necessary fields in response are missed" in { 90 | val app: Application = fakeApp(response = Results.Ok.sendResource("token-with-missed-token-type.json")) 91 | Server.withApplication(application = app) { port => 92 | WsTestClient.withClient { client => 93 | 94 | val request = iamClient(port, client, app.actorSystem) 95 | .apply(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")) 96 | 97 | Await.result(request, 5.second) must beEqualTo(None) 98 | 99 | } 100 | } 101 | } 102 | 103 | "should not return a Token Info when remote server operates slow" in { 104 | val app: Application = fakeApp(delay = 15.minutes) 105 | Server.withApplication(application = app) { port => 106 | WsTestClient.withClient { client => 107 | 108 | val request = iamClient(port, client, app.actorSystem) 109 | .apply(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")) 110 | 111 | // should drop a connection faster than in 5 seconds I think, so we await only 5 seconds for result 112 | Await.result(request, 5.seconds) must beEqualTo(None) 113 | 114 | } 115 | } 116 | } 117 | 118 | "should not return a Token Info when remote server is unavailable" in { 119 | val app: Application = fakeApp() 120 | Server.withApplication(application = app) { port => 121 | WsTestClient.withClient { client => 122 | 123 | // to emulate a connection timeout (not a request timeout) we need to try to establish 124 | // a connection to non-routable IP address. 125 | val additionalConfig = Map("authorisation.iam.endpoint" -> "http://10.0.0.1/oauth2/tokeninfo") 126 | val request = iamClient(port, client, app.actorSystem, additionalConfig) 127 | .apply(OAuth2Token("311f3ab2-4116-45a0-8bb0-50c3bca0441d")) 128 | 129 | // should drop a connection faster than in 5 seconds I think, so we await only 5 seconds for result 130 | Await.result(request, 5.second) must beEqualTo(None) 131 | 132 | } 133 | } 134 | } 135 | 136 | "stop calling remote server when failed requests rate exceed a threshold" in { 137 | val app: Application = fakeApp(delay = 15.minutes) 138 | Server.withApplication(application = app) { port => 139 | WsTestClient.withClient { wsClient => 140 | 141 | val client = iamClient(port, wsClient, app.actorSystem) 142 | 143 | // circuit breaker will be open after 5 attempts 144 | (1 to 5).foreach { attempt => 145 | Await.result(client.apply(OAuth2Token(attempt.toString)), 3.seconds) must beEqualTo(None) 146 | } 147 | 148 | // all later requests should fail fast 149 | Await.result(client.apply(OAuth2Token("any-token")), 1.second) must beEqualTo(None) 150 | } 151 | } 152 | } 153 | 154 | } 155 | 156 | def fakeApp(delay: Duration = 0.second, response: Result = Results.Ok): Application = { 157 | val routes: PartialFunction[(String, String), Handler] = { 158 | case ("GET", "/tokeninfo") => Action { 159 | Thread.sleep(delay.toMillis) 160 | response 161 | } 162 | } 163 | 164 | new GuiceApplicationBuilder() 165 | .in(Mode.Test) 166 | .routes(routes) 167 | .configure("play.akka.actor-system" -> s"application_iam_client_${java.util.UUID.randomUUID}") 168 | .build 169 | } 170 | 171 | private def iamClient(port: Port, client: WSClient, actorSystem: ActorSystem, 172 | additionalConfig: Map[String, Any] = Map.empty): IAMClient = { 173 | val clientConfig = Configuration.from(Map( 174 | "authorisation.iam.endpoint" -> s"http://localhost:$port/tokeninfo", 175 | "authorisation.iam.cb.maxFailures" -> 4, 176 | "authorisation.iam.cb.callTimeout" -> 2000, 177 | "authorisation.iam.cb.resetTimeout" -> 60000, 178 | "play.ws.timeout.connection" -> 2000, 179 | "play.ws.timeout.idle" -> 2000, 180 | "play.ws.timeout.request" -> 2000 181 | ) ++ additionalConfig) 182 | 183 | val metricsConfig = Configuration.from(Map( 184 | // generate new name each time so different registries are used 185 | "metrics.name" -> java.util.UUID.randomUUID.toString 186 | )) 187 | 188 | new IAMClient(clientConfig, new NoOpPlugableMetrics, client, actorSystem, ExecutionContext.Implicits.global) 189 | } 190 | } 191 | --------------------------------------------------------------------------------