From 91e59ab9cf743d9993bee112fbca5c04ab7ca71e Mon Sep 17 00:00:00 2001 From: Jeremy Smith Date: Tue, 4 Oct 2016 12:44:16 -0700 Subject: [PATCH] Add authentication framework - Add the ability to place filters before the HTTP service, configurable at the request level - Add the concept of an Authorizer, which is just a filter. - Add some initial Authorizers - Bearer (for pre-shared Bearer tokens) and MAC (for pre-shared MAC tokens) A future PR should contain a full OAuth2 flow implemented in an Authorizer. --- .../src/main/scala/featherbed/Client.scala | 35 +++-- .../scala/featherbed/auth/Authorizer.scala | 6 + .../main/scala/featherbed/auth/OAuth2.scala | 123 ++++++++++++++++ .../featherbed/request/RequestSyntax.scala | 133 +++++++++++------- 4 files changed, 237 insertions(+), 60 deletions(-) create mode 100644 featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala create mode 100644 featherbed-core/src/main/scala/featherbed/auth/OAuth2.scala diff --git a/featherbed-core/src/main/scala/featherbed/Client.scala b/featherbed-core/src/main/scala/featherbed/Client.scala index 93c0702..c584e09 100644 --- a/featherbed-core/src/main/scala/featherbed/Client.scala +++ b/featherbed-core/src/main/scala/featherbed/Client.scala @@ -5,17 +5,27 @@ import java.nio.charset.{Charset, StandardCharsets} import com.twitter.finagle._ import com.twitter.finagle.builder.ClientBuilder -import http.RequestBuilder +import featherbed.auth.Authorizer +import http.{Request, RequestBuilder, Response} import shapeless.Coproduct /** * A REST client with a given base URL. */ -class Client( +case class Client( baseUrl: URL, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] = Filter.identity[Request, Response] ) extends request.RequestTypes with request.RequestBuilding { + def addFilter(filter: Filter[Request, Response, Request, Response]): Client = + copy(filters = filter andThen filters) + + def setFilter(filter: Filter[Request, Response, Request, Response]): Client = + copy(filters = filter) + + def authorized(authorizer: Authorizer): Client = setFilter(filters andThen authorizer) + /** * Specify a GET request to be performed against the given resource * @param relativePath The path to the resource, relative to the baseUrl @@ -25,7 +35,8 @@ class Client( GetRequest[Coproduct.`"*/*"`.T]( baseUrl.toURI.resolve(relativePath).toURL, List.empty, - charset + charset, + filters ) /** @@ -38,7 +49,8 @@ class Client( baseUrl.toURI.resolve(relativePath).toURL, None, List.empty, - charset + charset, + filters ) /** @@ -51,7 +63,8 @@ class Client( baseUrl.toURI.resolve(relativePath).toURL, None, List.empty, - charset + charset, + filters ) /** @@ -60,7 +73,7 @@ class Client( * @return A [[HeadRequest]] object, which can further specify and send the request */ def head(relativePath: String): HeadRequest = - HeadRequest(baseUrl.toURI.resolve(relativePath).toURL, List.empty) + HeadRequest(baseUrl.toURI.resolve(relativePath).toURL, List.empty, charset, filters) /** * Specify a DELETE request to be performed against the given resource @@ -68,7 +81,7 @@ class Client( * @return A [[DeleteRequest]] object, which can further specify and send the request */ def delete(relativePath: String): DeleteRequest[Coproduct.`"*/*"`.T] = - DeleteRequest[Coproduct.`"*/*"`.T](baseUrl.toURI.resolve(relativePath).toURL, List.empty) + DeleteRequest[Coproduct.`"*/*"`.T](baseUrl.toURI.resolve(relativePath).toURL, List.empty, charset, filters) /** * Close this client releasing allocated resources. @@ -78,9 +91,11 @@ class Client( protected def clientTransform(client: Http.Client): Http.Client = client - protected val client = clientTransform(Client.forUrl(baseUrl)) + protected lazy val client = + clientTransform(Client.forUrl(baseUrl)) - protected[featherbed] val httpClient = client.newService(Client.hostAndPort(baseUrl)) + protected[featherbed] lazy val httpClient = + client.newService(Client.hostAndPort(baseUrl)) } object Client { diff --git a/featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala b/featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala new file mode 100644 index 0000000..a96c320 --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala @@ -0,0 +1,6 @@ +package featherbed.auth + +import com.twitter.finagle.Filter +import com.twitter.finagle.http.{Request, Response} + +trait Authorizer extends Filter[Request, Response, Request, Response] \ No newline at end of file diff --git a/featherbed-core/src/main/scala/featherbed/auth/OAuth2.scala b/featherbed-core/src/main/scala/featherbed/auth/OAuth2.scala new file mode 100644 index 0000000..e4e7044 --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/auth/OAuth2.scala @@ -0,0 +1,123 @@ +package featherbed.auth + +import java.nio.charset.{Charset, StandardCharsets} +import java.security.MessageDigest +import java.time.Instant +import java.util.{Base64, UUID} + +import com.twitter.finagle.Service +import com.twitter.finagle.http.{Request, Response} +import com.twitter.util.Future +import javax.crypto.spec.SecretKeySpec + +object OAuth2 { + + /** + * RFC 6750 - OAuth2 Bearer Token + * https://tools.ietf.org/html/rfc6750 + * + * @param token The OAuth2 Bearer Token + */ + case class Bearer(token: String) extends Authorizer { + def apply( + request: Request, + service: Service[Request, Response] + ): Future[Response] = { + request.authorization = s"Bearer $token" + service(request) + } + } + + /** + * IETF Draft for OAuth2 MAC Tokens + * https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-02 + * + * @param keyIdentifier The MAC Key Identifier + * @param macKey The MAC Secret Key + * @param algorithm The MAC Algorithm (Mac.Sha1 or Mac.SHA256) + * @param ext A function which computes some "extension text" to be covered by the MAC signature + */ + case class Mac( + keyIdentifier: String, + macKey: String, + algorithm: Mac.Algorithm, + ext: Request => Option[String] = (req) => None + ) extends Authorizer { + + import Mac._ + + def apply( + request: Request, + service: Service[Request, Response] + ): Future[Response] = { + val keyBytes = macKey.getBytes(requestCharset(request)) + val timestamp = Instant.now() + val nonce = UUID.randomUUID().toString + val signature = sign( + keyBytes, algorithm, request, timestamp, nonce, ext + ) + val authFields = List( + "id" -> keyIdentifier, + "timestamp" -> timestamp.getEpochSecond.toString, + "nonce" -> nonce, + "mac" -> Base64.getEncoder.encodeToString(signature) + ) ++ List(ext(request).map("ext" -> _)).flatten + + val auth = "MAC " + authFields.map { + case (key, value) => s""""$key"="$value"""" + }.mkString(", ") + request.authorization = auth + service(request) + } + } + + object Mac { + sealed trait Algorithm { + def name: String + } + case object Sha1 extends Algorithm { val name = "HmacSHA1" } + case object Sha256 extends Algorithm { val name = "HmacSHA256" } + + private def requestCharset(request: Request) = + request.charset.map(Charset.forName).getOrElse(StandardCharsets.UTF_8) + + private def sign( + key: Array[Byte], + algorithm: Mac.Algorithm, + request: Request, + timestamp: Instant, + nonce: String, + ext: Request => Option[String] + ) = { + val stringToSign = normalizedRequestString(request, timestamp, nonce, ext) + val signingKey = new SecretKeySpec(key, algorithm.name) + val mac = javax.crypto.Mac.getInstance(algorithm.name) + mac.init(signingKey) + mac.doFinal(stringToSign.getBytes(requestCharset(request))) + } + + private def normalizedRequestString( + request: Request, + timestamp: Instant, + nonce: String, + ext: Request => Option[String] + ) = { + val hostAndPort = request.host.map(_.span(_ == ':')).map { + case (h, p) => h -> Option(p.stripPrefix(":")).filter(_.nonEmpty) + } + val host = hostAndPort.map(_._1) + val port = hostAndPort.flatMap(_._2) + Seq( + timestamp.getEpochSecond.toString, + nonce, + request.method.toString().toUpperCase, + request.uri, + host.getOrElse(""), + port.getOrElse(request.remotePort.toString), + ext(request).getOrElse(""), + "" + ).mkString("\n") + } + } + +} diff --git a/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala b/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala index 6fe9736..8b4db9a 100644 --- a/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala +++ b/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala @@ -8,6 +8,7 @@ import scala.language.experimental.macros import cats.data._, Xor._, Validated._ import cats.instances.list._ +import com.twitter.finagle.Filter import com.twitter.finagle.http._ import com.twitter.finagle.http.Status._ import com.twitter.util.Future @@ -36,11 +37,15 @@ trait RequestTypes { self: Client => val url: URL val charset: Charset val headers: List[(String, String)] + val filters: Filter[Request, Response, Request, Response] def withHeader(name: String, value: String): Self = withHeaders((name, value)) def withHeaders(headers: (String, String)*): Self def withCharset(charset: Charset): Self def withUrl(url: URL): Self + def addFilter(filter: Filter[Request, Response, Request, Response]): Self + def resetFilters: Self + def setFilters(filter: Filter[Request, Response, Request, Response]): Self = resetFilters.addFilter(filter) def withQuery(query: String): Self = withUrl(new URL(url, url.getFile + "?" + query)) def withQueryParams(params: List[(String, String)]): Self = withQuery( @@ -79,49 +84,50 @@ trait RequestTypes { self: Client => out } - private def handleRequest(request: Request, numRedirects: Int = 0): Future[Response] = httpClient(request) flatMap { - rep => rep.status match { - case Continue => - Future.exception(InvalidResponse( - rep, - "Received unexpected 100/Continue, but request body was already sent." - )) - case SwitchingProtocols => - Future.exception(InvalidResponse( - rep, - "Received unexpected 101/Switching Protocols, but no switch was requested." - )) - case s if s.code >= 200 && s.code < 300 => - Future(rep) - case MultipleChoices => - Future.exception(InvalidResponse(rep, "300/Multiple Choices is not yet supported in featherbed.")) - case MovedPermanently | Found | SeeOther | TemporaryRedirect => - val attempt = for { - tooMany <- if (numRedirects > 5) - Xor.left("Too many redirects; giving up") - else - Xor.right(()) - location <- Xor.fromOption( - rep.headerMap.get("Location"), - "Redirect required, but location header not present") - newUrl <- Xor.catchNonFatal(url.toURI.resolve(location)) - .leftMap(_ => s"Could not resolve Location $location") - canHandle <- if (newUrl.getHost != url.getHost) - Xor.left("Location points to another host; this isn't supported by featherbed") - else - Xor.right(()) - } yield { - val newReq = cloneRequest(request) - newReq.uri = List(Option(newUrl.getPath), Option(newUrl.getQuery).map("?" + _)).flatten.mkString - handleRequest(newReq, numRedirects + 1) - } - attempt.fold( - err => Future.exception(InvalidResponse(rep, err)), - identity - ) - case other => Future.exception(ErrorResponse(request, rep)) + private def handleRequest(request: Request, numRedirects: Int = 0): Future[Response] = + (filters andThen httpClient)(request) flatMap { + rep => rep.status match { + case Continue => + Future.exception(InvalidResponse( + rep, + "Received unexpected 100/Continue, but request body was already sent." + )) + case SwitchingProtocols => + Future.exception(InvalidResponse( + rep, + "Received unexpected 101/Switching Protocols, but no switch was requested." + )) + case s if s.code >= 200 && s.code < 300 => + Future(rep) + case MultipleChoices => + Future.exception(InvalidResponse(rep, "300/Multiple Choices is not yet supported in featherbed.")) + case MovedPermanently | Found | SeeOther | TemporaryRedirect => + val attempt = for { + tooMany <- if (numRedirects > 5) + Xor.left("Too many redirects; giving up") + else + Xor.right(()) + location <- Xor.fromOption( + rep.headerMap.get("Location"), + "Redirect required, but location header not present") + newUrl <- Xor.catchNonFatal(url.toURI.resolve(location)) + .leftMap(_ => s"Could not resolve Location $location") + canHandle <- if (newUrl.getHost != url.getHost) + Xor.left("Location points to another host; this isn't supported by featherbed") + else + Xor.right(()) + } yield { + val newReq = cloneRequest(request) + newReq.uri = List(Option(newUrl.getPath), Option(newUrl.getQuery).map("?" + _)).flatten.mkString + handleRequest(newReq, numRedirects + 1) + } + attempt.fold( + err => Future.exception(InvalidResponse(rep, err)), + identity + ) + case other => Future.exception(ErrorResponse(request, rep)) + } } - } protected def decodeResponse[T](rep: Response)(implicit decodeAll: DecodeAll[T, Accept]) = @@ -197,7 +203,8 @@ trait RequestTypes { self: Client => case class GetRequest[Accept <: Coproduct]( url: URL, headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] ) extends RequestSyntax[Accept, GetRequest[Accept]] { def accept[AcceptTypes <: Coproduct]: GetRequest[AcceptTypes] = copy[AcceptTypes]() @@ -206,6 +213,9 @@ trait RequestTypes { self: Client => def withHeaders(addHeaders: (String, String)*): GetRequest[Accept] = copy(headers = headers ::: addHeaders.toList) def withCharset(charset: Charset): GetRequest[Accept] = copy(charset = charset) def withUrl(url: URL): GetRequest[Accept] = copy(url = url) + def addFilter(filter: Filter[Request, Response, Request, Response]): GetRequest[Accept] = + copy(filters = filter andThen filters) + def resetFilters: GetRequest[Accept] = copy(filters = Filter.identity[Request, Response]) def send[K]()(implicit canBuild: CanBuildRequest[GetRequest[Accept]], @@ -231,7 +241,8 @@ trait RequestTypes { self: Client => url: URL, content: Content, headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] ) extends RequestSyntax[Accept, PostRequest[Content, ContentType, Accept]] { def accept[AcceptTypes <: Coproduct]: PostRequest[Content, ContentType, AcceptTypes] = @@ -244,6 +255,9 @@ trait RequestTypes { self: Client => copy(charset = charset) def withUrl(url: URL): PostRequest[Content, ContentType, Accept] = copy(url = url) + def addFilter(filter: Filter[Request, Response, Request, Response]): PostRequest[Content, ContentType, Accept] = + copy(filters = filter andThen filters) + def resetFilters: PostRequest[Content, ContentType, Accept] = copy(filters = Filter.identity[Request, Response]) def withContent[T, Type <: String]( content: T, @@ -266,7 +280,9 @@ trait RequestTypes { self: Client => Right(NonEmptyList(firstElement, restElements)), multipart = false, headers, - charset) + charset, + filters + ) } def addParams( @@ -291,7 +307,8 @@ trait RequestTypes { self: Client => Right(NonEmptyList(element, Nil)), multipart = true, headers, - charset + charset, + filters ) } @@ -322,7 +339,8 @@ trait RequestTypes { self: Client => form: Elements = Left(None), multipart: Boolean = false, headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] ) extends RequestSyntax[Accept, FormPostRequest[Accept, Elements]] { def accept[AcceptTypes <: Coproduct]: FormPostRequest[AcceptTypes, Elements] = @@ -337,6 +355,9 @@ trait RequestTypes { self: Client => copy(url = url) def withMultipart(multipart: Boolean): FormPostRequest[Accept, Elements] = copy(multipart = multipart) + def addFilter(filter: Filter[Request, Response, Request, Response]): FormPostRequest[Accept, Elements] = + copy(filters = filter andThen filters) + def resetFilters: FormPostRequest[Accept, Elements] = copy(filters = Filter.identity[Request, Response]) private[request] def withParamsList(params: NonEmptyList[ValidatedNel[Throwable, FormElement]]) = copy[Accept, Right[NonEmptyList[ValidatedNel[Throwable, FormElement]]]]( @@ -406,7 +427,8 @@ trait RequestTypes { self: Client => url: URL, content: Content, headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] ) extends RequestSyntax[Accept, PutRequest[Content, ContentType, Accept]] { def accept[AcceptTypes <: Coproduct]: PutRequest[Content, ContentType, AcceptTypes] = @@ -419,6 +441,9 @@ trait RequestTypes { self: Client => copy(charset = charset) def withUrl(url: URL): PutRequest[Content, ContentType, Accept] = copy(url = url) + def addFilter(filter: Filter[Request, Response, Request, Response]): PutRequest[Content, ContentType, Accept] = + copy(filters = filter andThen filters) + def resetFilters: PutRequest[Content, ContentType, Accept] = copy(filters = Filter.identity[Request, Response]) def withContent[T, Type <: String]( content: T, @@ -449,12 +474,16 @@ trait RequestTypes { self: Client => case class HeadRequest( url: URL, headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] ) extends RequestSyntax[Nothing, HeadRequest] { def withHeaders(addHeaders: (String, String)*): HeadRequest = copy(headers = headers ::: addHeaders.toList) def withCharset(charset: Charset): HeadRequest = copy(charset = charset) def withUrl(url: URL): HeadRequest = copy(url = url) + def addFilter(filter: Filter[Request, Response, Request, Response]): HeadRequest = + copy(filters = filter andThen filters) + def resetFilters: HeadRequest = copy(filters = Filter.identity[Request, Response]) def send()(implicit canBuild: CanBuildRequest[HeadRequest], @@ -465,7 +494,8 @@ trait RequestTypes { self: Client => case class DeleteRequest[Accept <: Coproduct]( url: URL, headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] ) extends RequestSyntax[Accept, DeleteRequest[Accept]] { def accept[AcceptTypes <: Coproduct]: DeleteRequest[AcceptTypes] = copy[AcceptTypes]() @@ -475,6 +505,9 @@ trait RequestTypes { self: Client => copy(headers = headers ::: addHeaders.toList) def withCharset(charset: Charset): DeleteRequest[Accept] = copy(charset = charset) def withUrl(url: URL): DeleteRequest[Accept] = copy(url = url) + def addFilter(filter: Filter[Request, Response, Request, Response]): DeleteRequest[Accept] = + copy(filters = filter andThen filters) + def resetFilters: DeleteRequest[Accept] = copy(filters = Filter.identity[Request, Response]) def send[K]()(implicit canBuild: CanBuildRequest[DeleteRequest[Accept]],