From b3231b38d05fc6facb4977b4f4ec5c678e4ac27a Mon Sep 17 00:00:00 2001 From: Lydon da Rocha <69146037+TheLydonKing@users.noreply.github.com> Date: Mon, 21 Oct 2024 15:07:53 +0200 Subject: [PATCH] Add new spnego support to the documentation examples and possibly the client library (#105) * Add details to Readme * Added Kerberos support to client Library and Example * Add additional Info to Readme * Add Jaas config and kerberos to example config * Added new functionality to README * Update Readme with comments --- README.md | 147 +++++++++++++++++- clientLibrary/README.md | 11 +- .../client/TokenRetrievalClient.scala | 104 ++++++++++++- .../main/resources/example.application.yaml | 4 + examples/src/main/resources/example.jaas.conf | 8 + .../co/absa/clientexample/Application.scala | 56 ++++++- .../clientexample/config/ExampleConfig.scala | 5 +- project/Dependencies.scala | 3 +- 8 files changed, 315 insertions(+), 23 deletions(-) create mode 100644 examples/src/main/resources/example.jaas.conf diff --git a/README.md b/README.md index dd341fcf..cd1e0bf9 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,7 @@ On the side of the integrator, in order to trust the access token, one should do 2. verify that the access token 1. is valid against this public-key (e.g. using `jwtt` library or similar) 2. is not expired - 3. has `type=access` - + 3. has `type=access` ## API documentation: Swagger doc site is available at `http://localhost:port/swagger-ui.html` @@ -88,8 +87,6 @@ sbt service / Tomcat / start ``` - - ## Authentication Providers ### Enabling and Selecting Authentication Providers The Login Service allows users to select which authentication providers they would like to use @@ -141,6 +138,41 @@ Format of attributes list under LDAP in config is: `ldapFieldName` is the name of the field in the LDAP server and `claimName` is the name of the claim that will be added to the JWT token. +### Enabling SPNEGO authentication with Ldap +When Ldap authentication is used, there is the option of adding SPNEGO authentication via kerberos. +This will allow users to authenticate via Basic Auth or Kerberos Tickets. +The Config to enable this will look like this: +``` + ldap: + # Auth Protocol + # Set the order of the protocol starting from 1 + # Set to 0 to disable or simply exclude the ldap tag from config + # NOTE: At least 1 auth protocol needs to be enabled + order: 2 + domain: "some.domain.com" + url: "ldaps://some.domain.com:636/" + search-filter: "(samaccountname={1})" + service-account: + account-pattern: "CN=%s,OU=Users,OU=CORP Accounts,DC=corp,DC=dsarena,DC=com" + in-config-account: + username: "svc-ldap" + password: "password" + enable-kerberos: + krb-file-location: "/etc/krb5.conf" + keytab-file-location: "/etc/keytab" + spn: "HTTP/Host" + debug: true + attributes: + : "" +``` + +Adding the `enable-kerberos` property to the config will enable SPNEGO authentication. +In order to facilitate the kerberos authentication, you will need to provide a krb5 file that includes +the kerberos configuration details such as domains and Kerberos distribution center address. +A Keytab file needs to be created and attached to the service. The SPN needs to match that which +is used in the keytab and matches the host of the Login Service. The `debug` property is used when +additional information is required from the logs when testing the service. + ### ActiveDirectoryLDAPAuthenticationProvider Uses LDAP(s) to authenticate user in Active Directory and to fetch groups that this user belongs to. @@ -208,6 +240,113 @@ There are a few important configuration values to be provided: Please note that only one configuration option (`loginsvc.rest.jwt.{aws-secrets-manager|generate-in-memory}`) can be used at a time. +## Generating Tokens via SPNEGO/Kerberos +To securely authenticate and retrieve a JWT token from a server using Kerberos and SPNEGO, clients (both Windows and Linux) need to be properly configured for Kerberos authentication. +The process involves obtaining a Kerberos ticket and using it to authenticate to the endpoint. + +### Steps for Windows Clients +#### 1) Kerberos Configuration: + +- Ensure the Windows client is joined to the appropriate Active Directory (AD) domain. +- Verify that the Kerberos configuration is correct in the `krb5.ini` file, typically located in `C:\ProgramData\MIT\Kerberos5\` or `C:\Windows\`. +The `krb5.ini` file should include the correct realm and KDC settings. An example configuration might look like this: +``` +[libdefaults] + default_realm = YOURDOMAIN.COM + dns_lookup_realm = false + dns_lookup_kdc = true + +[realms] + YOURDOMAIN.COM = { + kdc = kdc.yourdomain.com + admin_server = kdc.yourdomain.com + } + +[domain_realm] + .yourdomain.com = YOURDOMAIN.COM + yourdomain.com = YOURDOMAIN.COM +``` +#### 2) Optional: MIT Kerberos Installation: + +- While Windows has built-in Kerberos support, you may choose to install MIT Kerberos if you need advanced features or compatibility with specific applications. +- Download the installer from the [MIT Kerberos website](https://web.mit.edu/kerberos/dist/). +- Follow the installation instructions, and ensure the krb5.ini file is properly configured as mentioned above. + +#### 3) Check Kerberos Tickets: + +- Use the `klist` command in the Command Prompt or use MIT Kerberos to verify the presence of a valid Kerberos ticket. +``` +Credentials cache: API:1000 + Principal: user@EXAMPLE.COM + Cache version: 5 + Ticket cache: /tmp/krb5cc_1000 + Default principal: user@EXAMPLE.COM + +Valid starting Expires Service principal +10/20/2024 10:00:00 10/20/2024 20:00:00 krbtgt/EXAMPLE.COM@EXAMPLE.COM +10/20/2024 10:00:00 10/20/2024 20:00:00 host/server.example.com@EXAMPLE.COM +``` + +#### 4) Environment Setup: + +- Ensure that the required libraries (e.g., SPNEGO) are available in your application or tool (e.g., Postman, Curl). + +#### 5) Sending the POST Request: + +- Construct a POST request to the desired endpoint. +- Example using Curl: +``` +curl -i --negotiate -u : -X POST /token/generate +``` + +#### 6) Receive the JWT: + +- On successful authentication, the server will respond with an access and refresh JWT tokens. + +### Steps for Linux Clients +#### 1) Kerberos Installation: + +- Install the necessary Kerberos packages (e.g., krb5-libs). + +#### 2) Kerberos Configuration: + +- Locate and, if necessary, replace krb5.conf. The krb5.conf file is typically located in /etc/krb5.conf. +- Ensure it includes the correct realm and KDC (Key Distribution Center) settings. A basic configuration might look like this: +``` +[libdefaults] + default_realm = YOURDOMAIN.COM + dns_lookup_realm = false + dns_lookup_kdc = true + +[realms] + YOURDOMAIN.COM = { + kdc = kdc.yourdomain.com + admin_server = kdc.yourdomain.com + } + +[domain_realm] + .yourdomain.com = YOURDOMAIN.COM + yourdomain.com = YOURDOMAIN.COM +``` + +#### 3) Obtaining a Kerberos Ticket: + +- Use the following command to obtain a Kerberos ticket (A password may be required): +``` +kinit username@YOURDOMAIN.COM +``` + +#### 4) Sending the POST Request: + +- Use a tool like Curl to send a POST request: +``` +curl -i --negotiate -u : -X POST /token/generate +``` + +#### 5) Receive the JWT: + +- On successful authentication, the server will respond with an access and refresh JWT tokens. + ## How to generate Code coverage report ``` sbt jacoco diff --git a/clientLibrary/README.md b/clientLibrary/README.md index d1d5b36c..cfb28e32 100644 --- a/clientLibrary/README.md +++ b/clientLibrary/README.md @@ -30,8 +30,14 @@ Public Key is available without authorization so just the relevant host needs to ## Token retrieval The library provides a `TokenRetrievalClient` class that can be used to retrieve access and refresh tokens. -Refresh and Access Keys require authorization. Basic Auth is used for the initial retrieval so a valid username and password is required. +Refresh and Access Keys require authorization. +There are 2 authentication methods available: +1) Basic Auth is used for the initial retrieval so a valid username and password is required. Please see the [login-service documentation](README.md) for more information on what a valid username and password is. +2) Spnego authentication. Please ensure that kerberos is enabled and configured correctly in your environment. +In order to support kerberos, we allow for the use of Keytabs as well as the use of Ticketcache authentication. +If required, you may specify a jaas configuration file and custom krb5 location programmatically using the `setKerberosProperties` function. + Refresh token from initial retrieval is used to refresh the access token. ## Creating and Using a JWT Decoder @@ -66,5 +72,4 @@ An example of how to use the library can be found in the [examples](examples) fo The example makes use of a [configuration file](examples/src/main/resources/example.application.yaml) to provide the necessary configuration to the library. Configurations required are: -1. `host` - the url of the login-service (Including Port if required) -2. 'refresh-period' - the period between refreshing the public-key used for verification. This Parameter is optional. \ No newline at end of file +1. `host` - the url of the login-service (Including Port if required) \ No newline at end of file diff --git a/clientLibrary/src/main/scala/za/co/absa/loginclient/tokenRetrieval/client/TokenRetrievalClient.scala b/clientLibrary/src/main/scala/za/co/absa/loginclient/tokenRetrieval/client/TokenRetrievalClient.scala index 05188289..d3f7c706 100644 --- a/clientLibrary/src/main/scala/za/co/absa/loginclient/tokenRetrieval/client/TokenRetrievalClient.scala +++ b/clientLibrary/src/main/scala/za/co/absa/loginclient/tokenRetrieval/client/TokenRetrievalClient.scala @@ -19,11 +19,13 @@ package za.co.absa.loginclient.tokenRetrieval.client import com.google.gson.{JsonObject, JsonParser} import org.slf4j.{Logger, LoggerFactory} import org.springframework.http.{HttpEntity, HttpHeaders, HttpMethod, MediaType, ResponseEntity} +import org.springframework.security.kerberos.client.KerberosRestTemplate import org.springframework.web.client.RestTemplate import za.co.absa.loginclient.tokenRetrieval.model.{AccessToken, RefreshToken} import java.net.URLEncoder -import java.util.Collections +import java.util.{Collections, Properties} +import javax.security.auth.login.Configuration /** * This class is used to retrieve tokens from the login service. @@ -45,10 +47,35 @@ case class TokenRetrievalClient(host: String) { * @param groups An optional list of PAM groups. If provided, only JWTs associated with these groups are returned if the user belongs to them. * @return An AccessToken object representing the retrieved access token (JWT) from the login service. */ - def fetchAccessToken(username: String, password: String, groups: List[String] = List.empty): AccessToken = { + def fetchAccessToken(username: String, password: String, groups: List[String]): AccessToken = { fetchAccessAndRefreshToken(username, password, groups)._1 } + /** + * This method requests an access token (JWT) from the login service using SPNEGO. + * This Token is used to access resources which utilize the login Service for authentication. + * + * @param keytabLocation Optional location of the keytab file. + * @param userPrincipal Optional userPrincipal name included in the above keytab file. + * @param groups An optional list of PAM groups. If provided, only JWTs associated with these groups are returned if the user belongs to them. + * @return An AccessToken object representing the retrieved access token (JWT) from the login service. + */ + def fetchAccessToken(keytabLocation: Option[String], userPrincipal: Option[String], groups: List[String]): AccessToken = { + fetchAccessAndRefreshToken(keytabLocation, userPrincipal, groups)._1 + } + + /** + * This method requests a refresh token from the login service using SPNEGO. + * This token may be used to acquire a new access token (JWT) when the current access token expires. + * + * @param keytabLocation Optional location of the keytab file. + * @param userPrincipal Optional userPrincipal name included in the above keytab file. + * @return A RefreshToken object representing the retrieved refresh token from the login service. + */ + def fetchRefreshToken(keytabLocation: Option[String], userPrincipal: Option[String]): RefreshToken = { + fetchAccessAndRefreshToken(keytabLocation, userPrincipal, List.empty)._2 + } + /** * This method requests a refresh token from the login service using the specified username and password. * This token may be used to acquire a new access token (JWT) when the current access token expires. @@ -58,7 +85,7 @@ case class TokenRetrievalClient(host: String) { * @return A RefreshToken object representing the retrieved refresh token from the login service. */ def fetchRefreshToken(username: String, password: String): RefreshToken = { - fetchAccessAndRefreshToken(username, password)._2 + fetchAccessAndRefreshToken(username, password, List.empty)._2 } /** @@ -71,7 +98,7 @@ case class TokenRetrievalClient(host: String) { * @param groups An optional list of PAM groups. If provided, only JWTs associated with these groups are returned if the user belongs to them. * @return A tuple containing the AccessToken and RefreshToken objects representing the retrieved access and refresh tokens (JWTs) from the login service. */ - def fetchAccessAndRefreshToken(username: String, password: String, groups: List[String] = List.empty): (AccessToken, RefreshToken) = { + def fetchAccessAndRefreshToken(username: String, password: String, groups: List[String]): (AccessToken, RefreshToken) = { val issuerUri = if(groups.nonEmpty) { val commaSeparatedString = groups.mkString(",") @@ -86,6 +113,31 @@ case class TokenRetrievalClient(host: String) { (AccessToken(accessToken), RefreshToken(refreshToken)) } + /** + * Fetches both an access token and a refresh token from the login service using SPNEGO. + * This method requests both an access token and a refresh token (JWTs) from the login service using kerberos, either with a keytab or the users cached ticket. + * Additionally, it allows specifying optional groups that act as filters for the JWT, returning only the JWTs associated with the provided groups if the user belongs to them. + * + * @param keytabLocation Optional location of the keytab file. + * @param userPrincipal Optional userPrincipal name included in the above keytab file. + * @param groups An optional list of PAM groups. If provided, only JWTs associated with these groups are returned if the user belongs to them. + * @return A tuple containing the AccessToken and RefreshToken objects representing the retrieved access and refresh tokens (JWTs) from the login service. + */ + def fetchAccessAndRefreshToken(keytabLocation: Option[String], userPrincipal: Option[String], groups: List[String]): (AccessToken, RefreshToken) = { + + val issuerUri = if(groups.nonEmpty) { + val commaSeparatedString = groups.mkString(",") + val urlEncodedGroups = URLEncoder.encode(commaSeparatedString, "UTF-8") + s"$host/token/generate?group-prefixes=$urlEncodedGroups" + } else s"$host/token/generate" + + val jsonString = fetchToken(issuerUri, keytabLocation, userPrincipal) + val jsonObject = JsonParser.parseString(jsonString).getAsJsonObject + val accessToken = jsonObject.get("token").getAsString + val refreshToken = jsonObject.get("refresh").getAsString + (AccessToken(accessToken), RefreshToken(refreshToken)) + } + def refreshAccessToken(accessToken: AccessToken, refreshToken: RefreshToken): (AccessToken, RefreshToken) = { val issuerUri = s"$host/token/refresh" @@ -124,6 +176,19 @@ case class TokenRetrievalClient(host: String) { } } + def setKerberosProperties(jaasFileLocation: String, krb5FileLocation: Option[String], debug: Option[Boolean]): Unit = { + val properties: Properties = new Properties() + properties.setProperty("java.security.auth.login.config", jaasFileLocation) + properties.setProperty("sun.security.krb5.debug", debug.getOrElse(false).toString) + + if(krb5FileLocation.nonEmpty) + properties.setProperty("java.security.krb5.conf", krb5FileLocation.get) + + Configuration.getConfiguration.refresh() + + System.setProperties(properties) + } + private def fetchToken(issuerUri: String, username: String, password: String): String = { logger.info(s"Fetching token from $issuerUri for user $username") @@ -152,4 +217,35 @@ case class TokenRetrievalClient(host: String) { throw e } } + + private def fetchToken(issuerUri: String, keyTabLocation: Option[String], userPrincipal: Option[String]): String = { + + val restTemplate: KerberosRestTemplate = (keyTabLocation, userPrincipal) match { + case (Some(_), Some(_)) => + logger.info(s"Fetching token from $issuerUri using user $userPrincipal") + new KerberosRestTemplate(keyTabLocation.get, userPrincipal.get) + case (None, None) => + logger.info(s"Fetching token from $issuerUri using cached user ticket") + new KerberosRestTemplate() + case _ => + throw new Error("Either both keyTabLocation and userPrincipal need to be available or omitted") + } + + val headers = new HttpHeaders() + val entity = new HttpEntity[String](null, headers) + + try { + val response: ResponseEntity[String] = restTemplate.exchange( + issuerUri, + HttpMethod.POST, + entity, + classOf[String]) + response.getBody + } + catch { + case e: Throwable => + logger.error(s"Error occurred retrieving and decoding Token from $issuerUri", e) + throw e + } + } } diff --git a/examples/src/main/resources/example.application.yaml b/examples/src/main/resources/example.application.yaml index ec3412ba..1493a31c 100644 --- a/examples/src/main/resources/example.application.yaml +++ b/examples/src/main/resources/example.application.yaml @@ -2,3 +2,7 @@ login-service: example: host: "http://localhost:9090" + #kerberos: + #jaas-file-location: "location/of/jaas.conf" + #krb-file-location: "location/of/krb5.conf" #Optional + #debug: "true" #Optional diff --git a/examples/src/main/resources/example.jaas.conf b/examples/src/main/resources/example.jaas.conf new file mode 100644 index 00000000..8b8942d3 --- /dev/null +++ b/examples/src/main/resources/example.jaas.conf @@ -0,0 +1,8 @@ +KerberosClient { + com.sun.security.auth.module.Krb5LoginModule required + useTicketCache=true + ticketCache="ticket/cache/location" + principal="principal.Domain.COM" + debug=true; #Enable debug output (optional) +}; + diff --git a/examples/src/main/scala/za/co/absa/clientexample/Application.scala b/examples/src/main/scala/za/co/absa/clientexample/Application.scala index e217f394..aaf9fc06 100644 --- a/examples/src/main/scala/za/co/absa/clientexample/Application.scala +++ b/examples/src/main/scala/za/co/absa/clientexample/Application.scala @@ -23,6 +23,7 @@ import za.co.absa.loginclient.tokenRetrieval.client.TokenRetrievalClient import java.nio.file.{Files, Paths} import java.util.Scanner +import util.control.Breaks._ object Application { @@ -45,16 +46,55 @@ object Application { var loggedIn = false while (true) { - println("----------------------------------------------") - println("---------------PLEASE LOGIN-------------------") - println("----------------------------------------------") - print("Enter your username: ") - val username = scanner.nextLine() - print("Enter your password: ") - val password = scanner.nextLine() + var username = "" + var password = "" + var authMethod = "" + breakable + { + while(true) + { + if(config.kerberos.nonEmpty) + { + val kerberosConfig = config.kerberos.get + tokenRetriever.setKerberosProperties(kerberosConfig.jaasFileLocation, kerberosConfig.krbFileLocation, kerberosConfig.debug) + println("Please choose authentication method:") + println("1) Basic Authentication") + println("2) SPNEGO") + print("Enter your choice: ") + authMethod = scanner.nextLine() + } + else + { + authMethod = "1" + } + authMethod match { + case "1" => + println("----------------------------------------------") + println("---------------PLEASE LOGIN-------------------") + println("----------------------------------------------") + println("") + print("Enter your username: ") + username = scanner.nextLine() + print("Enter your password: ") + password = scanner.nextLine() + break + case "2" => + break + case _ => + println("----------------------------------------------") + println(s"INVALID CHOICE. PLEASE TRY AGAIN") + println("----------------------------------------------") + } + } + } try { - val (accessToken, refreshToken) = tokenRetriever.fetchAccessAndRefreshToken(username, password) + val (accessToken, refreshToken) = authMethod match { + case "1" => + tokenRetriever.fetchAccessAndRefreshToken(username, password, List.empty) + case "2" => + tokenRetriever.fetchAccessAndRefreshToken(None, None, List.empty) + } val decodedAtJwt = accessVerificator.decodeAndVerifyAccessToken(accessToken) // throw Exception on verification fail loggedIn = true diff --git a/examples/src/main/scala/za/co/absa/clientexample/config/ExampleConfig.scala b/examples/src/main/scala/za/co/absa/clientexample/config/ExampleConfig.scala index 9d4ac6f3..d6c3fd9e 100644 --- a/examples/src/main/scala/za/co/absa/clientexample/config/ExampleConfig.scala +++ b/examples/src/main/scala/za/co/absa/clientexample/config/ExampleConfig.scala @@ -16,6 +16,5 @@ package za.co.absa.clientexample.config -import scala.concurrent.duration.FiniteDuration - -case class ExampleConfig(host: String) +case class ExampleConfig(host: String, kerberos: Option[KerberosConfig]) +case class KerberosConfig(jaasFileLocation: String, krbFileLocation: Option[String], debug: Option[Boolean]) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 7e3aae46..47465f78 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -47,7 +47,6 @@ object Dependencies { lazy val springSecurityKerberosClient = "org.springframework.security.kerberos" % "spring-security-kerberos-client" % "1.0.1.RELEASE" lazy val springSecurityKerberosWeb = "org.springframework.security.kerberos" % "spring-security-kerberos-web" % "1.0.1.RELEASE" - lazy val jjwtApi = "io.jsonwebtoken" % "jjwt-api" % Versions.jjwt lazy val jjwtImpl = "io.jsonwebtoken" % "jjwt-impl" % Versions.jjwt % Runtime lazy val jjwtJackson = "io.jsonwebtoken" % "jjwt-jackson" % Versions.jjwt % Runtime @@ -128,6 +127,8 @@ object Dependencies { springBootWeb, springBootSecurity, + springSecurityKerberosClient, + scalaTest )