+htp #15933 import + improve SecurityDirectives (+ infrastructure) from spray

This commit is contained in:
Johannes Rudolph 2014-11-03 16:27:38 +01:00
parent 34363374b6
commit dad5e568cd
9 changed files with 318 additions and 20 deletions

View file

@ -4,6 +4,8 @@
package akka.http.server package akka.http.server
import akka.http.marshalling.Marshaller
import akka.http.server.directives.AuthenticationDirectives._
import com.typesafe.config.{ ConfigFactory, Config } import com.typesafe.config.{ ConfigFactory, Config }
import scala.concurrent.duration._ import scala.concurrent.duration._
import akka.actor.ActorSystem import akka.actor.ActorSystem
@ -28,11 +30,24 @@ object TestServer extends App {
import ScalaRoutingDSL._ 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 { handleConnections(bindingFuture) withRoute {
get { get {
path("") { path("") {
complete(index) complete(index)
} ~ } ~
path("secure") {
HttpBasicAuthentication("My very secure site")(auth) { user
complete(<html><body>Hello <b>{ user }</b>. Access has been granted!</body></html>)
}
} ~
path("ping") { path("ping") {
complete("PONG!") complete("PONG!")
} ~ } ~
@ -47,16 +62,16 @@ object TestServer extends App {
Console.readLine() Console.readLine()
system.shutdown() system.shutdown()
lazy val index = HttpResponse( lazy val index =
entity = HttpEntity(MediaTypes.`text/html`, <html>
"""|<html> <body>
| <body> <h1>Say hello to <i>akka-http-core</i>!</h1>
| <h1>Say hello to <i>akka-http-core</i>!</h1> <p>Defined resources:</p>
| <p>Defined resources:</p> <ul>
| <ul> <li><a href="/ping">/ping</a></li>
| <li><a href="/ping">/ping</a></li> <li><a href="/secure">/secure</a> Use any username and '&lt;username&gt;-password' as credentials</li>
| <li><a href="/crash">/crash</a></li> <li><a href="/crash">/crash</a></li>
| </ul> </ul>
| </body> </body>
|</html>""".stripMargin)) </html>
} }

View file

@ -0,0 +1,79 @@
/*
* Copyright (C) 2009-2014 Typesafe Inc. <http://www.typesafe.com>
*/
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)
}
}
}
}

View file

@ -13,7 +13,7 @@ import FastFuture._
/** /**
* A directive that provides a tuple of values of type `L` to create an inner route. * 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`. * Calls the inner route with a tuple of extracted values of type `L`.

View file

@ -9,6 +9,7 @@ import directives._
// FIXME: the comments are kept as a reminder which directives are not yet imported // FIXME: the comments are kept as a reminder which directives are not yet imported
trait Directives extends RouteConcatenation trait Directives extends RouteConcatenation
with AuthenticationDirectives
with BasicDirectives with BasicDirectives
with CacheConditionDirectives with CacheConditionDirectives
//with ChunkingDirectives //with ChunkingDirectives
@ -30,6 +31,6 @@ trait Directives extends RouteConcatenation
with RespondWithDirectives with RespondWithDirectives
with RouteDirectives with RouteDirectives
with SchemeDirectives with SchemeDirectives
//with SecurityDirectives with SecurityDirectives
object Directives extends Directives object Directives extends Directives

View file

@ -5,7 +5,7 @@
package akka.http.server package akka.http.server
import akka.http.model._ import akka.http.model._
import akka.http.model.headers.{ ByteRange, HttpEncoding } import akka.http.model.headers.{ HttpChallenge, ByteRange, HttpEncoding }
import scala.collection.immutable import scala.collection.immutable
@ -125,7 +125,7 @@ case class UnacceptedResponseEncodingRejection(supported: HttpEncoding) extends
* specified in the cause. * specified in the cause.
*/ */
case class AuthenticationFailedRejection(cause: AuthenticationFailedRejection.Cause, case class AuthenticationFailedRejection(cause: AuthenticationFailedRejection.Cause,
challengeHeaders: List[HttpHeader]) extends Rejection challenge: HttpChallenge) extends Rejection
object AuthenticationFailedRejection { object AuthenticationFailedRejection {
/** /**

View file

@ -31,14 +31,23 @@ object RejectionHandler {
def default(implicit ec: ExecutionContext) = apply(default = true) { def default(implicit ec: ExecutionContext) = apply(default = true) {
case Nil complete(NotFound, "The requested resource could not be found.") case Nil complete(NotFound, "The requested resource could not be found.")
case AuthenticationFailedRejection(cause, challengeHeaders) +: _ case rejections @ (AuthenticationFailedRejection(cause, _) +: _)
val rejectionMessage = cause match { val rejectionMessage = cause match {
case CredentialsMissing "The resource requires authentication, which was not supplied with the request" case CredentialsMissing "The resource requires authentication, which was not supplied with the request"
case CredentialsRejected "The supplied authentication is invalid" 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") complete(Forbidden, "The supplied authentication is not authorized to access this resource")
case MalformedFormFieldRejection(name, msg, _) +: _ case MalformedFormFieldRejection(name, msg, _) +: _

View file

@ -0,0 +1,169 @@
/*
* Copyright (C) 2009-2014 Typesafe Inc. <http://www.typesafe.com>
*/
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))
}

View file

@ -16,7 +16,7 @@ trait CookieDirectives {
/** /**
* Extracts an HttpCookie with the given name. If the cookie is not present the * 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] = def cookie(name: String): Directive1[HttpCookie] =
headerValue(findCookie(name)) | reject(MissingCookieRejection(name)) headerValue(findCookie(name)) | reject(MissingCookieRejection(name))

View file

@ -0,0 +1,25 @@
/*
* Copyright (C) 2009-2014 Typesafe Inc. <http://www.typesafe.com>
*/
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)
}