Skip to content

Commit 91e59ab

Browse files
author
Jeremy Smith
committed
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.
1 parent 9d69c39 commit 91e59ab

File tree

4 files changed

+237
-60
lines changed

4 files changed

+237
-60
lines changed

featherbed-core/src/main/scala/featherbed/Client.scala

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,27 @@ import java.nio.charset.{Charset, StandardCharsets}
55

66
import com.twitter.finagle._
77
import com.twitter.finagle.builder.ClientBuilder
8-
import http.RequestBuilder
8+
import featherbed.auth.Authorizer
9+
import http.{Request, RequestBuilder, Response}
910
import shapeless.Coproduct
1011

1112
/**
1213
* A REST client with a given base URL.
1314
*/
14-
class Client(
15+
case class Client(
1516
baseUrl: URL,
16-
charset: Charset = StandardCharsets.UTF_8
17+
charset: Charset = StandardCharsets.UTF_8,
18+
filters: Filter[Request, Response, Request, Response] = Filter.identity[Request, Response]
1719
) extends request.RequestTypes with request.RequestBuilding {
1820

21+
def addFilter(filter: Filter[Request, Response, Request, Response]): Client =
22+
copy(filters = filter andThen filters)
23+
24+
def setFilter(filter: Filter[Request, Response, Request, Response]): Client =
25+
copy(filters = filter)
26+
27+
def authorized(authorizer: Authorizer): Client = setFilter(filters andThen authorizer)
28+
1929
/**
2030
* Specify a GET request to be performed against the given resource
2131
* @param relativePath The path to the resource, relative to the baseUrl
@@ -25,7 +35,8 @@ class Client(
2535
GetRequest[Coproduct.`"*/*"`.T](
2636
baseUrl.toURI.resolve(relativePath).toURL,
2737
List.empty,
28-
charset
38+
charset,
39+
filters
2940
)
3041

3142
/**
@@ -38,7 +49,8 @@ class Client(
3849
baseUrl.toURI.resolve(relativePath).toURL,
3950
None,
4051
List.empty,
41-
charset
52+
charset,
53+
filters
4254
)
4355

4456
/**
@@ -51,7 +63,8 @@ class Client(
5163
baseUrl.toURI.resolve(relativePath).toURL,
5264
None,
5365
List.empty,
54-
charset
66+
charset,
67+
filters
5568
)
5669

5770
/**
@@ -60,15 +73,15 @@ class Client(
6073
* @return A [[HeadRequest]] object, which can further specify and send the request
6174
*/
6275
def head(relativePath: String): HeadRequest =
63-
HeadRequest(baseUrl.toURI.resolve(relativePath).toURL, List.empty)
76+
HeadRequest(baseUrl.toURI.resolve(relativePath).toURL, List.empty, charset, filters)
6477

6578
/**
6679
* Specify a DELETE request to be performed against the given resource
6780
* @param relativePath The path to the resource, relative to the baseUrl
6881
* @return A [[DeleteRequest]] object, which can further specify and send the request
6982
*/
7083
def delete(relativePath: String): DeleteRequest[Coproduct.`"*/*"`.T] =
71-
DeleteRequest[Coproduct.`"*/*"`.T](baseUrl.toURI.resolve(relativePath).toURL, List.empty)
84+
DeleteRequest[Coproduct.`"*/*"`.T](baseUrl.toURI.resolve(relativePath).toURL, List.empty, charset, filters)
7285

7386
/**
7487
* Close this client releasing allocated resources.
@@ -78,9 +91,11 @@ class Client(
7891

7992
protected def clientTransform(client: Http.Client): Http.Client = client
8093

81-
protected val client = clientTransform(Client.forUrl(baseUrl))
94+
protected lazy val client =
95+
clientTransform(Client.forUrl(baseUrl))
8296

83-
protected[featherbed] val httpClient = client.newService(Client.hostAndPort(baseUrl))
97+
protected[featherbed] lazy val httpClient =
98+
client.newService(Client.hostAndPort(baseUrl))
8499
}
85100

86101
object Client {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package featherbed.auth
2+
3+
import com.twitter.finagle.Filter
4+
import com.twitter.finagle.http.{Request, Response}
5+
6+
trait Authorizer extends Filter[Request, Response, Request, Response]
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package featherbed.auth
2+
3+
import java.nio.charset.{Charset, StandardCharsets}
4+
import java.security.MessageDigest
5+
import java.time.Instant
6+
import java.util.{Base64, UUID}
7+
8+
import com.twitter.finagle.Service
9+
import com.twitter.finagle.http.{Request, Response}
10+
import com.twitter.util.Future
11+
import javax.crypto.spec.SecretKeySpec
12+
13+
object OAuth2 {
14+
15+
/**
16+
* RFC 6750 - OAuth2 Bearer Token
17+
* https://tools.ietf.org/html/rfc6750
18+
*
19+
* @param token The OAuth2 Bearer Token
20+
*/
21+
case class Bearer(token: String) extends Authorizer {
22+
def apply(
23+
request: Request,
24+
service: Service[Request, Response]
25+
): Future[Response] = {
26+
request.authorization = s"Bearer $token"
27+
service(request)
28+
}
29+
}
30+
31+
/**
32+
* IETF Draft for OAuth2 MAC Tokens
33+
* https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-02
34+
*
35+
* @param keyIdentifier The MAC Key Identifier
36+
* @param macKey The MAC Secret Key
37+
* @param algorithm The MAC Algorithm (Mac.Sha1 or Mac.SHA256)
38+
* @param ext A function which computes some "extension text" to be covered by the MAC signature
39+
*/
40+
case class Mac(
41+
keyIdentifier: String,
42+
macKey: String,
43+
algorithm: Mac.Algorithm,
44+
ext: Request => Option[String] = (req) => None
45+
) extends Authorizer {
46+
47+
import Mac._
48+
49+
def apply(
50+
request: Request,
51+
service: Service[Request, Response]
52+
): Future[Response] = {
53+
val keyBytes = macKey.getBytes(requestCharset(request))
54+
val timestamp = Instant.now()
55+
val nonce = UUID.randomUUID().toString
56+
val signature = sign(
57+
keyBytes, algorithm, request, timestamp, nonce, ext
58+
)
59+
val authFields = List(
60+
"id" -> keyIdentifier,
61+
"timestamp" -> timestamp.getEpochSecond.toString,
62+
"nonce" -> nonce,
63+
"mac" -> Base64.getEncoder.encodeToString(signature)
64+
) ++ List(ext(request).map("ext" -> _)).flatten
65+
66+
val auth = "MAC " + authFields.map {
67+
case (key, value) => s""""$key"="$value""""
68+
}.mkString(", ")
69+
request.authorization = auth
70+
service(request)
71+
}
72+
}
73+
74+
object Mac {
75+
sealed trait Algorithm {
76+
def name: String
77+
}
78+
case object Sha1 extends Algorithm { val name = "HmacSHA1" }
79+
case object Sha256 extends Algorithm { val name = "HmacSHA256" }
80+
81+
private def requestCharset(request: Request) =
82+
request.charset.map(Charset.forName).getOrElse(StandardCharsets.UTF_8)
83+
84+
private def sign(
85+
key: Array[Byte],
86+
algorithm: Mac.Algorithm,
87+
request: Request,
88+
timestamp: Instant,
89+
nonce: String,
90+
ext: Request => Option[String]
91+
) = {
92+
val stringToSign = normalizedRequestString(request, timestamp, nonce, ext)
93+
val signingKey = new SecretKeySpec(key, algorithm.name)
94+
val mac = javax.crypto.Mac.getInstance(algorithm.name)
95+
mac.init(signingKey)
96+
mac.doFinal(stringToSign.getBytes(requestCharset(request)))
97+
}
98+
99+
private def normalizedRequestString(
100+
request: Request,
101+
timestamp: Instant,
102+
nonce: String,
103+
ext: Request => Option[String]
104+
) = {
105+
val hostAndPort = request.host.map(_.span(_ == ':')).map {
106+
case (h, p) => h -> Option(p.stripPrefix(":")).filter(_.nonEmpty)
107+
}
108+
val host = hostAndPort.map(_._1)
109+
val port = hostAndPort.flatMap(_._2)
110+
Seq(
111+
timestamp.getEpochSecond.toString,
112+
nonce,
113+
request.method.toString().toUpperCase,
114+
request.uri,
115+
host.getOrElse(""),
116+
port.getOrElse(request.remotePort.toString),
117+
ext(request).getOrElse(""),
118+
""
119+
).mkString("\n")
120+
}
121+
}
122+
123+
}

0 commit comments

Comments
 (0)