From dad5e568cddd9f98f0b28de77a7785d8245878a7 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Mon, 3 Nov 2014 16:27:38 +0100 Subject: [PATCH] +htp #15933 import + improve SecurityDirectives (+ infrastructure) from spray --- .../scala/akka/http/server/TestServer.scala | 39 ++-- .../AuthenticationDirectivesSpec.scala | 79 ++++++++ .../scala/akka/http/server/Directive.scala | 2 +- .../scala/akka/http/server/Directives.scala | 3 +- .../scala/akka/http/server/Rejection.scala | 4 +- .../akka/http/server/RejectionHandler.scala | 15 +- .../directives/AuthenticationDirectives.scala | 169 ++++++++++++++++++ .../server/directives/CookieDirectives.scala | 2 +- .../directives/SecurityDirectives.scala | 25 +++ 9 files changed, 318 insertions(+), 20 deletions(-) create mode 100644 akka-http-tests/src/test/scala/akka/http/server/directives/AuthenticationDirectivesSpec.scala create mode 100644 akka-http/src/main/scala/akka/http/server/directives/AuthenticationDirectives.scala create mode 100644 akka-http/src/main/scala/akka/http/server/directives/SecurityDirectives.scala diff --git a/akka-http-tests/src/test/scala/akka/http/server/TestServer.scala b/akka-http-tests/src/test/scala/akka/http/server/TestServer.scala index 3538dd3200..e966ef07f6 100644 --- a/akka-http-tests/src/test/scala/akka/http/server/TestServer.scala +++ b/akka-http-tests/src/test/scala/akka/http/server/TestServer.scala @@ -4,6 +4,8 @@ package akka.http.server +import akka.http.marshalling.Marshaller +import akka.http.server.directives.AuthenticationDirectives._ import com.typesafe.config.{ ConfigFactory, Config } import scala.concurrent.duration._ import akka.actor.ActorSystem @@ -28,11 +30,24 @@ object TestServer extends App { import ScalaRoutingDSL._ + def auth = + HttpBasicAuthenticator.provideUserName { + case p @ UserCredentials.Provided(name) ⇒ p.verifySecret(name + "-password") + case _ ⇒ false + } + + implicit val html = Marshaller.nodeSeqMarshaller(MediaTypes.`text/html`) + handleConnections(bindingFuture) withRoute { get { path("") { complete(index) } ~ + path("secure") { + HttpBasicAuthentication("My very secure site")(auth) { user ⇒ + complete(Hello { user }. Access has been granted!) + } + } ~ path("ping") { complete("PONG!") } ~ @@ -47,16 +62,16 @@ object TestServer extends App { Console.readLine() system.shutdown() - lazy val index = HttpResponse( - entity = HttpEntity(MediaTypes.`text/html`, - """| - | - |

Say hello to akka-http-core!

- |

Defined resources:

- | - | - |""".stripMargin)) + lazy val index = + + +

Say hello to akka-http-core!

+

Defined resources:

+ + + } diff --git a/akka-http-tests/src/test/scala/akka/http/server/directives/AuthenticationDirectivesSpec.scala b/akka-http-tests/src/test/scala/akka/http/server/directives/AuthenticationDirectivesSpec.scala new file mode 100644 index 0000000000..b29cb3c6b2 --- /dev/null +++ b/akka-http-tests/src/test/scala/akka/http/server/directives/AuthenticationDirectivesSpec.scala @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2009-2014 Typesafe Inc. + */ + +package akka.http.server +package directives + +import akka.http.model._ +import akka.http.model.headers._ +import akka.http.server.AuthenticationFailedRejection.{ CredentialsRejected, CredentialsMissing } +import akka.http.server.directives.AuthenticationDirectives._ + +import scala.concurrent.Future + +class AuthenticationDirectivesSpec extends RoutingSpec { + val dontAuth = HttpBasicAuthentication("MyRealm")(HttpBasicAuthenticator[String](_ ⇒ Future.successful(None))) + val doAuth = HttpBasicAuthentication("MyRealm")(HttpBasicAuthenticator.provideUserName(_ ⇒ true)) + val authWithAnonymous = doAuth.withAnonymousUser("We are Legion") + + val challenge = HttpChallenge("Basic", "MyRealm") + + "the 'HttpBasicAuthentication' directive" should { + "reject requests without Authorization header with an AuthenticationFailedRejection" in { + Get() ~> { + dontAuth { echoComplete } + } ~> check { rejection shouldEqual AuthenticationFailedRejection(CredentialsMissing, challenge) } + } + "reject unauthenticated requests with Authorization header with an AuthenticationFailedRejection" in { + Get() ~> Authorization(BasicHttpCredentials("Bob", "")) ~> { + dontAuth { echoComplete } + } ~> check { rejection shouldEqual AuthenticationFailedRejection(CredentialsRejected, challenge) } + } + "reject requests with illegal Authorization header with 401" in { + Get() ~> RawHeader("Authorization", "bob alice") ~> sealRoute { + dontAuth { echoComplete } + } ~> check { + status shouldEqual StatusCodes.Unauthorized + responseAs[String] shouldEqual "The resource requires authentication, which was not supplied with the request" + header[`WWW-Authenticate`] shouldEqual Some(`WWW-Authenticate`(challenge)) + } + } + "extract the object representing the user identity created by successful authentication" in { + Get() ~> Authorization(BasicHttpCredentials("Alice", "")) ~> { + doAuth { echoComplete } + } ~> check { responseAs[String] shouldEqual "Alice" } + } + "extract the object representing the user identity created for the anonymous user" in { + Get() ~> { + authWithAnonymous { echoComplete } + } ~> check { responseAs[String] shouldEqual "We are Legion" } + } + "properly handle exceptions thrown in its inner route" in { + object TestException extends RuntimeException + Get() ~> Authorization(BasicHttpCredentials("Alice", "")) ~> { + sealRoute { + doAuth { _ ⇒ throw TestException } + } + } ~> check { status shouldEqual StatusCodes.InternalServerError } + } + } + "AuthenticationDirectives facilities" should { + "properly stack several authentication directives" in { + val otherChallenge = HttpChallenge("MyAuth", "MyRealm2") + val otherAuth: Directive1[String] = AuthenticationDirectives.authenticateOrRejectWithChallenge { (cred: Option[HttpCredentials]) ⇒ + Future.successful(Left(otherChallenge)) + } + val bothAuth = dontAuth | otherAuth + + Get() ~> sealRoute { + bothAuth { echoComplete } + } ~> check { + status shouldEqual StatusCodes.Unauthorized + headers.collect { + case `WWW-Authenticate`(challenge +: Nil) ⇒ challenge + } shouldEqual Seq(challenge, otherChallenge) + } + } + } +} diff --git a/akka-http/src/main/scala/akka/http/server/Directive.scala b/akka-http/src/main/scala/akka/http/server/Directive.scala index c867b82be4..cdc0ec738b 100644 --- a/akka-http/src/main/scala/akka/http/server/Directive.scala +++ b/akka-http/src/main/scala/akka/http/server/Directive.scala @@ -13,7 +13,7 @@ import FastFuture._ /** * A directive that provides a tuple of values of type `L` to create an inner route. */ -sealed abstract class Directive[L] private (implicit val ev: Tuple[L]) { +abstract class Directive[L](implicit val ev: Tuple[L]) { /** * Calls the inner route with a tuple of extracted values of type `L`. diff --git a/akka-http/src/main/scala/akka/http/server/Directives.scala b/akka-http/src/main/scala/akka/http/server/Directives.scala index 20df4eef19..1c0cd55c78 100644 --- a/akka-http/src/main/scala/akka/http/server/Directives.scala +++ b/akka-http/src/main/scala/akka/http/server/Directives.scala @@ -9,6 +9,7 @@ import directives._ // FIXME: the comments are kept as a reminder which directives are not yet imported trait Directives extends RouteConcatenation + with AuthenticationDirectives with BasicDirectives with CacheConditionDirectives //with ChunkingDirectives @@ -30,6 +31,6 @@ trait Directives extends RouteConcatenation with RespondWithDirectives with RouteDirectives with SchemeDirectives -//with SecurityDirectives + with SecurityDirectives object Directives extends Directives diff --git a/akka-http/src/main/scala/akka/http/server/Rejection.scala b/akka-http/src/main/scala/akka/http/server/Rejection.scala index 6b03411349..6ce710c05e 100644 --- a/akka-http/src/main/scala/akka/http/server/Rejection.scala +++ b/akka-http/src/main/scala/akka/http/server/Rejection.scala @@ -5,7 +5,7 @@ package akka.http.server import akka.http.model._ -import akka.http.model.headers.{ ByteRange, HttpEncoding } +import akka.http.model.headers.{ HttpChallenge, ByteRange, HttpEncoding } import scala.collection.immutable @@ -125,7 +125,7 @@ case class UnacceptedResponseEncodingRejection(supported: HttpEncoding) extends * specified in the cause. */ case class AuthenticationFailedRejection(cause: AuthenticationFailedRejection.Cause, - challengeHeaders: List[HttpHeader]) extends Rejection + challenge: HttpChallenge) extends Rejection object AuthenticationFailedRejection { /** diff --git a/akka-http/src/main/scala/akka/http/server/RejectionHandler.scala b/akka-http/src/main/scala/akka/http/server/RejectionHandler.scala index 130438b5df..2ad4615194 100644 --- a/akka-http/src/main/scala/akka/http/server/RejectionHandler.scala +++ b/akka-http/src/main/scala/akka/http/server/RejectionHandler.scala @@ -31,14 +31,23 @@ object RejectionHandler { def default(implicit ec: ExecutionContext) = apply(default = true) { case Nil ⇒ complete(NotFound, "The requested resource could not be found.") - case AuthenticationFailedRejection(cause, challengeHeaders) +: _ ⇒ + case rejections @ (AuthenticationFailedRejection(cause, _) +: _) ⇒ val rejectionMessage = cause match { case CredentialsMissing ⇒ "The resource requires authentication, which was not supplied with the request" case CredentialsRejected ⇒ "The supplied authentication is invalid" } - ctx ⇒ ctx.complete(Unauthorized, challengeHeaders, rejectionMessage) + val challenges = rejections.collect { case AuthenticationFailedRejection(_, challenge) ⇒ challenge } + // Multiple challenges per WWW-Authenticate header are allowed per spec, + // however, it seems many browsers will ignore all challenges but the first. + // Therefore, multiple WWW-Authenticate headers are rendered, instead. + // + // See https://code.google.com/p/chromium/issues/detail?id=103220 + // and https://bugzilla.mozilla.org/show_bug.cgi?id=669675 + val authenticateHeaders = challenges.map(`WWW-Authenticate`(_)) - case AuthorizationFailedRejection +: _ ⇒ + complete(Unauthorized, authenticateHeaders, rejectionMessage) + + case AuthorizationFailedRejection +: _ ⇒ complete(Forbidden, "The supplied authentication is not authorized to access this resource") case MalformedFormFieldRejection(name, msg, _) +: _ ⇒ diff --git a/akka-http/src/main/scala/akka/http/server/directives/AuthenticationDirectives.scala b/akka-http/src/main/scala/akka/http/server/directives/AuthenticationDirectives.scala new file mode 100644 index 0000000000..ab6e7c4e4b --- /dev/null +++ b/akka-http/src/main/scala/akka/http/server/directives/AuthenticationDirectives.scala @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2009-2014 Typesafe Inc. + */ + +package akka.http.server +package directives + +import scala.reflect.ClassTag + +import scala.concurrent.{ ExecutionContext, Future } + +import akka.http.util._ +import akka.http.util.FastFuture._ + +import akka.http.model.headers._ +import akka.http.server.AuthenticationFailedRejection.{ CredentialsRejected, CredentialsMissing } + +/** + * Provides directives for securing an inner route using the standard Http authentication headers [[`WWW-Authenticate`]] + * and [[Authorization]]. Most prominently, HTTP Basic authentication as defined in RFC 2617. + */ +trait AuthenticationDirectives { + import BasicDirectives._ + import AuthenticationDirectives._ + + /** + * The result of an HTTP authentication attempt is either the user object or + * an HttpChallenge to present to the browser. + */ + type AuthenticationResult[+T] = Either[HttpChallenge, T] + + /** + * Given [[UserCredentials]] the HttpBasicAuthenticator + * returns a Future of either the authenticated user object or None of the user + * couldn't be authenticated. + */ + type HttpBasicAuthenticator[T] = UserCredentials ⇒ Future[Option[T]] + + object HttpBasicAuthentication { + def challengeFor(realm: String) = HttpChallenge(scheme = "Basic", realm = realm, params = Map.empty) + + /** + * A directive that wraps the inner route with Http Basic authentication support. The given authenticator + * is used to determine if the credentials in the request are valid and which user object to supply + * to the inner route. + */ + def apply[T](realm: String)(authenticator: HttpBasicAuthenticator[T]): AuthenticationDirective[T] = + extractExecutionContext.flatMap { implicit ctx ⇒ + authenticateOrRejectWithChallenge[BasicHttpCredentials, T] { basic ⇒ + authenticator(authDataFor(basic)).fast.map { + case Some(t) ⇒ AuthenticationResult.success(t) + case None ⇒ AuthenticationResult.failWithChallenge(challengeFor(realm)) + } + } + } + + private def authDataFor(cred: Option[BasicHttpCredentials]): UserCredentials = + cred match { + case Some(BasicHttpCredentials(username, receivedSecret)) ⇒ + new UserCredentials.Provided(username) { + def verifySecret(secret: String): Boolean = secret secure_== receivedSecret + } + case None ⇒ UserCredentials.Missing + } + } +} + +object AuthenticationDirectives extends AuthenticationDirectives { + import BasicDirectives._ + import RouteDirectives._ + import FutureDirectives._ + import HeaderDirectives._ + + /** + * Represents authentication credentials supplied with a request. Credentials can either be + * [[UserCredentials.Missing]] or can be [[UserCredentials.Provided]] in which case a username is + * supplied and a function to check the known secret against the provided one in a secure fashion. + */ + sealed trait UserCredentials + object UserCredentials { + case object Missing extends UserCredentials + abstract case class Provided(username: String) extends UserCredentials { + def verifySecret(secret: String): Boolean + } + } + + object AuthenticationResult { + def success[T](user: T): AuthenticationResult[T] = Right(user) + def failWithChallenge(challenge: HttpChallenge): AuthenticationResult[Nothing] = Left(challenge) + } + + object HttpBasicAuthenticator { + implicit def apply[T](f: UserCredentials ⇒ Future[Option[T]]): HttpBasicAuthenticator[T] = + new HttpBasicAuthenticator[T] { + def apply(credentials: UserCredentials): Future[Option[T]] = f(credentials) + } + def fromPF[T](pf: PartialFunction[UserCredentials, Future[T]])(implicit ec: ExecutionContext): HttpBasicAuthenticator[T] = + new HttpBasicAuthenticator[T] { + def apply(credentials: UserCredentials): Future[Option[T]] = + if (pf.isDefinedAt(credentials)) pf(credentials).fast.map(Some(_)) + else FastFuture.successful(None) + } + + def checkAndProvide[T](check: UserCredentials.Provided ⇒ Boolean)(provide: String ⇒ T)(implicit ec: ExecutionContext): HttpBasicAuthenticator[T] = + HttpBasicAuthenticator.fromPF { + case p @ UserCredentials.Provided(name) if check(p) ⇒ FastFuture.successful(provide(name)) + } + def provideUserName(check: UserCredentials.Provided ⇒ Boolean)(implicit ec: ExecutionContext): HttpBasicAuthenticator[String] = + checkAndProvide(check)(identity) + } + + /** + * Lifts an authenticator function into a directive. The authenticator function gets passed in credentials from the + * [[Authorization]] header of the request. If the function returns ``Right(user)`` the user object is provided + * to the inner route. If the function returns ``Left(challenge)`` the request is rejected with an + * [[AuthenticationFailedRejection]] that contains this challenge to be added to the response. + * + */ + def authenticateOrRejectWithChallenge[T](authenticator: Option[HttpCredentials] ⇒ Future[AuthenticationResult[T]]): AuthenticationDirective[T] = + extractExecutionContext.flatMap { implicit ctx ⇒ + extractCredentials.flatMap { cred ⇒ + onSuccess(authenticator(cred)).flatMap { + case Right(user) ⇒ provide(user) + case Left(challenge) ⇒ + val cause = if (cred.isEmpty) CredentialsMissing else CredentialsRejected + reject(AuthenticationFailedRejection(cause, challenge)): Directive1[T] + } + } + } + + /** + * Lifts an authenticator function into a directive. Same as ``authenticateOrRejectWithChallenge`` above but only applies + * the authenticator function with a certain type of credentials. + */ + def authenticateOrRejectWithChallenge[C <: HttpCredentials: ClassTag, T](authenticator: Option[C] ⇒ Future[AuthenticationResult[T]]): AuthenticationDirective[T] = + authenticateOrRejectWithChallenge[T] { cred ⇒ + authenticator { + cred.collect { + case c: C ⇒ c + } + } + } + + trait AuthenticationDirective[T] extends Directive1[T] { + /** + * Returns a copy of this authenticationDirective that will provide ``Some(user)`` if credentials + * were supplied and otherwise ``None``. + */ + def optional: Directive1[Option[T]] = + this.map(Some(_): Option[T]).recover { + case AuthenticationFailedRejection(CredentialsMissing, _) +: _ ⇒ provide(None) + case rejs ⇒ reject(rejs: _*) + } + + /** + * Returns a copy of this authenticationDirective that uses the given object as the + * anonymous user which will be used if no credentials were supplied in the request. + */ + def withAnonymousUser(anonymous: T): Directive1[T] = + optional.map(_.getOrElse(anonymous)) + } + object AuthenticationDirective { + implicit def apply[T](other: Directive1[T]): AuthenticationDirective[T] = + new AuthenticationDirective[T] { def tapply(inner: Tuple1[T] ⇒ Route) = other.tapply(inner) } + } + + def extractCredentials: Directive1[Option[HttpCredentials]] = + optionalHeaderValueByType[`Authorization`]().map(_.map(_.credentials)) +} \ No newline at end of file diff --git a/akka-http/src/main/scala/akka/http/server/directives/CookieDirectives.scala b/akka-http/src/main/scala/akka/http/server/directives/CookieDirectives.scala index e5752b98d9..ddfa99ee00 100644 --- a/akka-http/src/main/scala/akka/http/server/directives/CookieDirectives.scala +++ b/akka-http/src/main/scala/akka/http/server/directives/CookieDirectives.scala @@ -16,7 +16,7 @@ trait CookieDirectives { /** * Extracts an HttpCookie with the given name. If the cookie is not present the - * request is rejected with a respective [[spray.routing.MissingCookieRejection]]. + * request is rejected with a respective [[MissingCookieRejection]]. */ def cookie(name: String): Directive1[HttpCookie] = headerValue(findCookie(name)) | reject(MissingCookieRejection(name)) diff --git a/akka-http/src/main/scala/akka/http/server/directives/SecurityDirectives.scala b/akka-http/src/main/scala/akka/http/server/directives/SecurityDirectives.scala new file mode 100644 index 0000000000..c164b1b272 --- /dev/null +++ b/akka-http/src/main/scala/akka/http/server/directives/SecurityDirectives.scala @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2009-2014 Typesafe Inc. + */ + +package akka.http.server +package directives + +import BasicDirectives._ +import RouteDirectives._ + +trait SecurityDirectives { + /** + * Applies the given authorization check to the request. + * If the check fails the route is rejected with an [[AuthorizationFailedRejection]]. + */ + def authorize(check: ⇒ Boolean): Directive0 = authorize(_ ⇒ check) + + /** + * Applies the given authorization check to the request. + * If the check fails the route is rejected with an [[AuthorizationFailedRejection]]. + */ + def authorize(check: RequestContext ⇒ Boolean): Directive0 = + extract(check).flatMap[Unit](if (_) pass else reject(AuthorizationFailedRejection)) & + cancelRejection(AuthorizationFailedRejection) +}