+htp #15933 import + improve SecurityDirectives (+ infrastructure) from spray
This commit is contained in:
parent
34363374b6
commit
dad5e568cd
9 changed files with 318 additions and 20 deletions
|
|
@ -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 '<username>-password' as credentials</li>
|
||||
<li><a href="/crash">/crash</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue