-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
finagle-http: Integrate kerberos server authentication in Finagle
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
Showing
4 changed files
with
223 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
118 changes: 118 additions & 0 deletions
118
finagle-http/src/main/scala/com/twitter/finagle/http/KerberosAuthenticationFilter.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} | ||
} |
57 changes: 57 additions & 0 deletions
57
finagle-http/src/test/scala/com/twitter/finagle/http/KerberosAuthenticationFilterTest.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} | ||
} |