+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
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,10 +30,23 @@ 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(<html><body>Hello <b>{ user }</b>. Access has been granted!</body></html>)
}
} ~
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`,
"""|<html>
| <body>
| <h1>Say hello to <i>akka-http-core</i>!</h1>
| <p>Defined resources:</p>
| <ul>
| <li><a href="/ping">/ping</a></li>
| <li><a href="/crash">/crash</a></li>
| </ul>
| </body>
|</html>""".stripMargin))
lazy val index =
<html>
<body>
<h1>Say hello to <i>akka-http-core</i>!</h1>
<p>Defined resources:</p>
<ul>
<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>
</ul>
</body>
</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.
*/
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`.

View file

@ -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

View file

@ -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 {
/**

View file

@ -31,12 +31,21 @@ 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`(_))
complete(Unauthorized, authenticateHeaders, rejectionMessage)
case AuthorizationFailedRejection +: _
complete(Forbidden, "The supplied authentication is not authorized to access this resource")

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
* 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))

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)
}