Skip to content

Commit

Permalink
finagle-http: Integrate kerberos server authentication in Finagle
Browse files Browse the repository at this point in the history
Problem
-------
Kerberos is one of the widely used authentication mechanisms across twitter services but onboarding/setup still requires quite a reasonable amount of redundant code and related knowledge which could be further abstracted. Most services just use the standard but redundant configuration based implementation. This requires an overhead to implement and maintain the code base. During onboarding if any of the components is misconfigured, it could result in complex triage or debugging. There is a learning curve involved on how to configure kerberos with onboarding challenges.

Solution
--------
Integrating kerberos at finagle level will reduce service overhead to provide standard configuration and boilerplate code. Introducing a stack based approach would only require invoking a `withKerberos` method with essential parameters like principal and keytab path. This will avoid any boilerplate code to configure jaas or krb5 with some essential parameters. Instantiating spnego authenticator as part of configuring https server would let service owners abstract the redundant setup. Defining a KerberosConfiguration object will also allow service owners to configure less frequently used parameters.

```

/** enable kerberos server authentication for http requests
*/
def withKerberos(kerberosConfiguration: KerberosConfiguration): Server

Usage:
override protected def configureHttpsServer(server: Http.Server): Http.Server = {
    super.configureHttpsServer(server)
      .withKerberos(KerberosConfiguration(
        principal = Some("example@example.com"),
        keyTab = Some("/path/to/keytab"))
      )

}

```
Result
------
Given above usage criteria, it will help service owners to easily and correctly configure kerberos for server as well as client authentication. This should significantly reduce onboarding time and effort to use kerberos authentication by service owners.

Note: This diff only includes kerberos server integration in finagle. I will have a similar following diff for client kerberos integration in finagle.

JIRA Issues: PSEC-10408

Differential Revision: https://phabricator.twitter.biz/D621714
  • Loading branch information
Alpit Kumar authored and jenkins committed Mar 5, 2021
1 parent 7ad4a33 commit eefc21c
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,36 @@ object CompressionLevel {
implicit val compressionLevelParam: Stack.Param[CompressionLevel] =
Stack.Param(CompressionLevel(-1))
}

/**
* Case class to configure jaas params
*
* @param principal The name of the principal that should be used
* @param keyTab Set this to the file name of the keytab to get principal's secret key
* @param useKeyTab Set this to true if you want the module to get the principal's key from the the keytab
* @param storeKey Set this to true to if you want the keytab or the principal's key to be stored in
* the Subject's private credentials
* @param refreshKrb5Config Set this to true, if you want the configuration to be refreshed before
* the login method is called
* @param debug Output debug messages
* @param doNotPrompt Set this to true if you do not want to be prompted for the password if
* credentials can not be obtained from the cache, the keytab, or through shared state
* @see [[https://docs.oracle.com/javase/7/docs/jre/api/security/jaas/spec/com/sun/security/auth/module/Krb5LoginModule.html]]
*/
case class KerberosConfiguration(
principal: Option[String] = None,
keyTab: Option[String] = None,
useKeyTab: Boolean = true,
storeKey: Boolean = true,
refreshKrb5Config: Boolean = true,
debug: Boolean = false,
doNotPrompt: Boolean = true,
authEnabled: Boolean = true)
case class Kerberos(kerberosConfiguration: KerberosConfiguration) {
def mk(): (Kerberos, Stack.Param[Kerberos]) =
(this, Kerberos.kerberosParam)
}
object Kerberos {
implicit val kerberosParam: Stack.Param[Kerberos] =
Stack.Param(Kerberos(KerberosConfiguration()))
}
15 changes: 15 additions & 0 deletions finagle-http/src/main/scala/com/twitter/finagle/Http.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.twitter.finagle.http._
import com.twitter.finagle.http.codec.HttpServerDispatcher
import com.twitter.finagle.http.exp.StreamTransport
import com.twitter.finagle.http.filter._
import com.twitter.finagle.http.param.KerberosConfiguration
import com.twitter.finagle.http.service.HttpResponseClassifier
import com.twitter.finagle.http2.Http2Listener
import com.twitter.finagle.netty4.http.{Netty4HttpListener, Netty4ServerStreamTransport}
Expand Down Expand Up @@ -381,6 +382,7 @@ object Http extends Client[Request, Response] with HttpRichClient with Server[Re
.prepend(
new Stack.NoOpModule(http.filter.StatsFilter.role, http.filter.StatsFilter.description)
)
.prepend(KerberosAuthenticationFilter.module)
.insertAfter(http.filter.StatsFilter.role, StreamingStatsFilter.module)
// the backup request module adds tracing annotations and as such must come
// after trace initialization and deserialization of contexts.
Expand Down Expand Up @@ -522,6 +524,19 @@ object Http extends Client[Request, Response] with HttpRichClient with Server[Re
def withNoHttp2: Server =
configured(HttpImpl.Netty4Impl)

/**
* Enable kerberos server authentication for http requests
*/
def withKerberos(kerberosConfiguration: KerberosConfiguration): Server = {
require(
kerberosConfiguration.principal.exists(_.trim.nonEmpty),
"Valid Kerberos principal must be specified")
require(
kerberosConfiguration.keyTab.exists(_.trim.nonEmpty),
"Valid Kerberos keytab path must be specified")
configured(http.param.Kerberos(kerberosConfiguration))
}

/**
* By default finagle-http automatically sends 100-CONTINUE responses to inbound
* requests which set the 'Expect: 100-Continue' header. Streaming servers will
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.twitter.finagle.http

import com.twitter.finagle.http.SpnegoAuthenticator.{Credentials, ServerFilter}
import com.twitter.finagle.http.param.{Kerberos, KerberosConfiguration}
import com.twitter.finagle.{Filter, Service, ServiceFactory, SimpleFilter, Stack, Stackable}
import com.twitter.util.{Future, FuturePool}
import java.io.PrintWriter
import java.nio.file.FileSystems

object AuthenticatedIdentityContext {
private val AuthenticatedIdentityNotSet = "AUTH_USER_NOT_SET"
private val AuthenticatedIdentity = Request.Schema.newField[String](AuthenticatedIdentityNotSet)

/**
* @see AuthenticatedIdentity [[https://web.mit.edu/kerberos/krb5-1.5/krb5-1.5.4/doc/krb5-user/What-is-a-Kerberos-Principal_003f.html]]
*/
implicit class AuthenticatedIdentityContextSyntax(val request: Request) extends AnyVal {
def authenticatedIdentity: String = request.ctx(AuthenticatedIdentity).takeWhile(_ != '@')
}

private[twitter] def setUser(request: Request, username: String): Unit = {
request.ctx.updateAndLock(AuthenticatedIdentity, username)
}
}

object ExtractAuthAndCatchUnauthorized
extends Filter[SpnegoAuthenticator.Authenticated[Request], Response, Request, Response] {
def apply(
req: SpnegoAuthenticator.Authenticated[Request],
svc: Service[Request, Response]
): Future[Response] = {
val httpRequest = req.request
AuthenticatedIdentityContext.setUser(httpRequest, req.context.getSrcName.toString)
svc(httpRequest).map { resp =>
if (resp.status == Status.Unauthorized) {
resp.contentType = "application/json; charset=utf-8"
resp.setContentString("""
{
"error": "You are not authenticated."
}
""")
}
resp
}
}
}

/**
* A finagle authentication filter.
* This calls an underlying finagle filter (SpnegoAuthenticator) and then applies a second "conversion"
* filter to convert the request to a request object that is compatible with finagle.
* @see JaasConfiguration [[https://docs.oracle.com/javase/7/docs/jre/api/security/jaas/spec/com/sun/security/auth/module/Krb5LoginModule.html]]
*/
object Spnego {
private def pool: FuturePool = FuturePool.unboundedPool
def apply(kerberosConfiguration: KerberosConfiguration): Future[ServerFilter] = pool {
val jaas = "jaas.conf"
val jaasConfiguration =
s"""kerberos-http {
|com.sun.security.auth.module.Krb5LoginModule required
| keyTab="${kerberosConfiguration.keyTab.get}"
| principal="${kerberosConfiguration.principal.get}"
| useKeyTab=${kerberosConfiguration.useKeyTab}
| storeKey=${kerberosConfiguration.storeKey}
| refreshKrb5Config=${kerberosConfiguration.refreshKrb5Config}
| debug=${kerberosConfiguration.debug}
| doNotPrompt=${kerberosConfiguration.doNotPrompt}
| authEnabled=${kerberosConfiguration.authEnabled};
| };""".stripMargin
new PrintWriter(jaas) {
write(jaasConfiguration)
close()
}
val jaasFilePath = FileSystems.getDefault.getPath(jaas).toAbsolutePath.toString
System.setProperty("java.security.auth.login.config", jaasFilePath)
ServerFilter(new Credentials.JAASServerSource("kerberos-http"))
}
}

/**
* Chain Spnego async filter with extractAuthAndCatchUnauthorized filter
*/
private[finagle] class AndThenAsync[Req, IntermediateReq, Rep](
async: Future[Filter[Req, Rep, IntermediateReq, Rep]],
other: Filter[IntermediateReq, Rep, Req, Rep])
extends SimpleFilter[Req, Rep] {
private val composedFilters: Future[Filter[Req, Rep, Req, Rep]] =
async.map(_.andThen(other))
def apply(req: Req, svc: Service[Req, Rep]): Future[Rep] = {
composedFilters.flatMap(_.apply(req, svc))
}
}

/**
* Adds kerberos authentication to http requests.
*/
object KerberosAuthenticationFilter {
val role: Stack.Role = Stack.Role("KerberosAuthentication")
def module: Stackable[ServiceFactory[Request, Response]] = {
new Stack.Module1[Kerberos, ServiceFactory[
Request,
Response
]] {
val role: Stack.Role = KerberosAuthenticationFilter.role
val description = "Add kerberos authentication to requests"
def make(
krb: Kerberos,
next: ServiceFactory[Request, Response]
): ServiceFactory[Request, Response] = {
if (krb.kerberosConfiguration.principal.nonEmpty && krb.kerberosConfiguration.keyTab.nonEmpty && krb.kerberosConfiguration.authEnabled) {
new AndThenAsync[Request, SpnegoAuthenticator.Authenticated[Request], Response](
Spnego(krb.kerberosConfiguration),
ExtractAuthAndCatchUnauthorized).andThen(next)
} else next
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.twitter.finagle.http

import com.twitter.conversions.DurationOps._
import com.twitter.finagle.http.param.KerberosConfiguration
import com.twitter.finagle.{Service, SimpleFilter}
import com.twitter.util.{Await, Future, Promise}
import org.scalatest.FunSuite

class KerberosAuthenticationFilterTest extends FunSuite {
private val filter =
new AndThenAsync[Request, SpnegoAuthenticator.Authenticated[Request], Response](
Spnego(KerberosConfiguration(Some("test-principal@twitter.biz"), Some("/keytab/path"))),
ExtractAuthAndCatchUnauthorized)
private val exampleService = new Service[Request, Response] {
def apply(request: Request): Future[Response] = {
val response = Response(request)
Future.value(response)
}
}
private def service: Service[Request, Response] = filter.andThen {
Service.mk { exampleService }
}
private val request = Request("/test.json")
request.method = Method.Get

test("successfully test auth header response") {
val response = Await.result(filter(request, service), 1.second)
assert(response.headerMap.nonEmpty)
assert(response.headerMap.get(Fields.WwwAuthenticate).contains("Negotiate"))
}
}

class AndThenAsyncTest extends FunSuite {
case class TestFilter(prefix: String, suffix: String) extends SimpleFilter[String, String] {
def apply(
request: String,
service: Service[String, String]
): Future[String] = service(s"$prefix $request").map(x => s"$x $suffix")
}
val testService = new Service[String, String] {
def apply(request: String): Future[String] = Future(request)
}

test("test async filter chained with other filter") {
val p: Promise[SimpleFilter[String, String]] = Promise()
val svc =
new AndThenAsync[String, String, String](p, TestFilter("prefix2", "suffix2"))
.andThen(testService)
val andThenAsyncVal = svc("test")
assert(!p.isDefined && !andThenAsyncVal.isDefined)

p.setValue(TestFilter("prefix1", "suffix1"))
val result = Await.result(andThenAsyncVal)
assert(p.isDefined && result.nonEmpty)
assert(result == "prefix2 prefix1 test suffix2 suffix1")
}
}

0 comments on commit eefc21c

Please sign in to comment.