Skip to content

Commit

Permalink
Merge pull request #71 from ing-bank/npa-token
Browse files Browse the repository at this point in the history
NPA token expiration
  • Loading branch information
kr7ysztof authored Jul 25, 2023
2 parents f793d3b + 2bc2bc0 commit 9ca4237
Show file tree
Hide file tree
Showing 16 changed files with 138 additions and 95 deletions.
14 changes: 7 additions & 7 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -35,25 +35,25 @@ libraryDependencies ++= Seq(
"com.typesafe.akka" %% "akka-http-spray-json" % akkaHttpVersion,
"com.typesafe.akka" %% "akka-http-xml" % akkaHttpVersion,
"com.typesafe.scala-logging" %% "scala-logging" % "3.9.2",
"ch.qos.logback" % "logback-classic" % "1.4.1",
"ch.qos.logback" % "logback-classic" % "1.4.7",
"com.typesafe.akka" %% "akka-slf4j" % akkaVersion,
"org.keycloak" % "keycloak-core" % keycloakVersion,
"org.keycloak" % "keycloak-adapter-core" % keycloakVersion,
"org.keycloak" % "keycloak-admin-client" % keycloakVersion,
"org.jboss.logging" % "jboss-logging" % "3.5.0.Final",
"org.apache.httpcomponents" % "httpclient" % "4.5.13",
"org.apache.httpcomponents" % "httpclient" % "4.5.14",
"ch.qos.logback.contrib" % "logback-json-classic" % logbackJson,
"ch.qos.logback.contrib" % "logback-jackson" % logbackJson,
"com.auth0" % "java-jwt" % "4.0.0",
"com.auth0" % "java-jwt" % "4.3.0",
"com.bettercloud" % "vault-java-driver" % "5.1.0",
"redis.clients" % "jedis" % "4.3.0-m1",
"org.scalatest" %% "scalatest" % "3.2.13" % "test, it",
"redis.clients" % "jedis" % "4.4.0",
"org.scalatest" %% "scalatest" % "3.2.15" % "test, it",
"com.typesafe.akka" %% "akka-http-testkit" % akkaHttpVersion % Test,
"com.typesafe.akka" %% "akka-stream-testkit" % akkaVersion % Test,
"com.amazonaws" % "aws-java-sdk-sts" % "1.12.307" % IntegrationTest,
"com.amazonaws" % "aws-java-sdk-sts" % "1.12.471" % IntegrationTest,
)
dependencyOverrides ++= Seq(
"com.fasterxml.jackson.core" % "jackson-databind" % "2.14.2",
"com.fasterxml.jackson.core" % "jackson-databind" % "2.15.1",
)

configs(IntegrationTest)
Expand Down
2 changes: 1 addition & 1 deletion src/it/scala/com/ing/wbaa/rokku/sts/StsServiceItTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class StsServiceItTest extends AsyncWordSpec with Diagrams
override protected[this] def getToken(awsSessionToken: AwsSessionToken, username: Username): Future[Option[(Username, UserAssumeRole, AwsSessionTokenExpiration)]] =
Future.successful(None)

override def generateAwsSession(duration: Option[Duration]): AwsSession = AwsSession(
override def generateAwsSession(duration: Duration): AwsSession = AwsSession(
AwsSessionToken("sessiontoken" + Random.alphanumeric.take(32).mkString),
AwsSessionTokenExpiration(Instant.now().plusSeconds(20))
)
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ rokku {

defaultTokenSessionHours = ${?STS_DEFAULT_TOKEN_SESSION_HOURS}
maxTokenSessionHours = ${?STS_MAX_TOKEN_SESSION_HOURS}
maxTokenSessionForNPAHours = ${?STS_MAX_TOKEN_SESSION_FOR_NPA_HOURS}
# at least 32 bytes long. Make sure you set your own random key
masterKey = ${?STS_MASTER_KEY}
encryptionAlgorithm = ${?STS_ENCRYPTION_ALGORITHM}
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ rokku {

defaultTokenSessionHours = 8
maxTokenSessionHours = 24
maxTokenSessionForNPAHours = 8760 #one year
masterKey = "MakeSureYouChangeMasterKeyToRandomString"
encryptionAlgorithm = "AES"
adminGroups = ""
Expand Down
44 changes: 29 additions & 15 deletions src/main/scala/com/ing/wbaa/rokku/sts/api/STSApi.scala
Original file line number Diff line number Diff line change
@@ -1,45 +1,59 @@
package com.ing.wbaa.rokku.sts.api

import java.util.concurrent.TimeUnit

import akka.http.scaladsl.model._
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import com.ing.wbaa.rokku.sts.api.xml.TokenXML
import com.ing.wbaa.rokku.sts.config.StsSettings
import com.ing.wbaa.rokku.sts.data._
import com.ing.wbaa.rokku.sts.data.aws._
import com.typesafe.scalalogging.LazyLogging
import directive.STSDirectives.{ authorizeToken, assumeRole }
import directive.STSDirectives.{ assumeRole, authorizeToken }

import scala.concurrent.Future
import scala.concurrent.duration._
import scala.util.{ Failure, Success }

trait STSApi extends LazyLogging with TokenXML {

protected[this] def stsSettings: StsSettings

private val getOrPost = get | post & pathSingleSlash
private val actionDirective = parameter("Action") | formField("Action")

private val parseDurationSeconds: Option[Int] => Option[Duration] =
_.map(durationSeconds => Duration(durationSeconds, TimeUnit.SECONDS))
private def parseDurationSeconds(aui: AuthenticationUserInfo, durationSeconds: Option[Int]): Duration = {
val maxTokenSession = if (aui.isNPA) stsSettings.maxTokenSessionForNPADuration else stsSettings.maxTokenSessionDuration
logger.debug("maxTokenSession {}", maxTokenSession)
val durationRequested = durationSeconds.map(ds => Duration(ds, TimeUnit.SECONDS))
val durationResult = durationRequested match {
case None => stsSettings.defaultTokenSessionDuration
case Some(durationRequested) =>
if (durationRequested > maxTokenSession) maxTokenSession
else durationRequested
}
logger.debug("durationResult {}", durationResult)
durationResult
}

private val getSessionTokenInputs = {
private def getSessionTokenInputs(aui: AuthenticationUserInfo) = {
val input = "DurationSeconds".as[Int].?
(parameter(input) & formField(input)).tmap {
case (param, field) =>
if (param.isDefined) parseDurationSeconds(param)
else parseDurationSeconds(field)
if (param.isDefined) parseDurationSeconds(aui, param)
else parseDurationSeconds(aui, field)
}
}

private val assumeRoleInputs = {
private def assumeRoleInputs(aui: AuthenticationUserInfo) = {
(parameters("RoleArn", "RoleSessionName", "DurationSeconds".as[Int].?) | formFields("RoleArn", "RoleSessionName", "DurationSeconds".as[Int].?)).tmap(t =>
t.copy(_1 = AwsRoleArn(t._1), _3 = parseDurationSeconds(t._3))
t.copy(_1 = AwsRoleArn(t._1), _3 = parseDurationSeconds(aui, t._3))
)
}

protected[this] def getAwsCredentialWithToken(userName: Username, userGroups: Set[UserGroup], duration: Option[Duration]): Future[AwsCredentialWithToken]
protected[this] def getAwsCredentialWithToken(userName: Username, userGroups: Set[UserGroup], role: UserAssumeRole, duration: Option[Duration]): Future[AwsCredentialWithToken]
protected[this] def getAwsCredentialWithToken(userName: Username, userGroups: Set[UserGroup], duration: Duration): Future[AwsCredentialWithToken]

protected[this] def getAwsCredentialWithToken(userName: Username, userGroups: Set[UserGroup], role: UserAssumeRole, duration: Duration): Future[AwsCredentialWithToken]

// Keycloak
protected[this] def verifyAuthenticationToken(token: BearerToken): Option[AuthenticationUserInfo]
Expand All @@ -58,8 +72,8 @@ trait STSApi extends LazyLogging with TokenXML {
}

private def getSessionTokenHandler: Route = {
getSessionTokenInputs { durationSeconds =>
authorizeToken(verifyAuthenticationToken) { keycloakUserInfo =>
authorizeToken(verifyAuthenticationToken) { keycloakUserInfo =>
getSessionTokenInputs(keycloakUserInfo) { durationSeconds =>
onComplete(getAwsCredentialWithToken(keycloakUserInfo.userName, keycloakUserInfo.userGroups, durationSeconds)) {
case Success(awsCredentialWithToken) => complete(getSessionTokenResponseToXML(awsCredentialWithToken))
case Failure(ex) =>
Expand All @@ -72,8 +86,8 @@ trait STSApi extends LazyLogging with TokenXML {
}

private def assumeRoleHandler: Route = {
assumeRoleInputs { (roleArn, roleSessionName, durationSeconds) =>
authorizeToken(verifyAuthenticationToken) { keycloakUserInfo =>
authorizeToken(verifyAuthenticationToken) { keycloakUserInfo =>
assumeRoleInputs(keycloakUserInfo) { (roleArn, roleSessionName, durationSeconds) =>
assumeRole(keycloakUserInfo, roleArn) { assumeRole =>
onComplete(getAwsCredentialWithToken(keycloakUserInfo.userName, keycloakUserInfo.userGroups, assumeRole, durationSeconds)) {
case Success(awsCredentialWithToken) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class StsSettings(config: Config) extends Extension {
private[this] val rokkuStsConfig = config.getConfig("rokku.sts")
val defaultTokenSessionDuration: Duration = Duration(rokkuStsConfig.getInt("defaultTokenSessionHours"), TimeUnit.HOURS)
val maxTokenSessionDuration: Duration = Duration(rokkuStsConfig.getInt("maxTokenSessionHours"), TimeUnit.HOURS)
val maxTokenSessionForNPADuration: Duration = Duration(rokkuStsConfig.getInt("maxTokenSessionForNPAHours"), TimeUnit.HOURS)
val masterKey: String = rokkuStsConfig.getString("masterKey")
val encryptionAlgorithm: String = rokkuStsConfig.getString("encryptionAlgorithm")
val adminGroups = rokkuStsConfig.getString("adminGroups").split(",").map(_.trim).toList
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ case class AuthenticationUserInfo(
userName: Username,
userGroups: Set[UserGroup],
keycloakTokenId: AuthenticationTokenId,
userRoles: Set[UserAssumeRole])
userRoles: Set[UserAssumeRole],
isNPA: Boolean)
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ trait KeycloakTokenVerifier extends LazyLogging {

import scala.jdk.CollectionConverters._

/**
* Temporary we define NPA by Name - later we will change it to some keycloak role
* @param keycloakToken
* @return true if NPA
*/
private def isNPA(keycloakToken: AccessToken): Boolean = {
val isNPA = keycloakToken.getName == "NPA NPA"
logger.debug("user getName={}", keycloakToken.getName)
logger.debug("is NPA={}", isNPA)
isNPA
}

protected[this] def verifyAuthenticationToken(token: BearerToken): Option[AuthenticationUserInfo] = Try {

val accessToken = TokenVerifier.create(token.value, classOf[AccessToken])
Expand All @@ -39,7 +51,8 @@ trait KeycloakTokenVerifier extends LazyLogging {
.getOrDefault("user-groups", new util.ArrayList[String]())
.asInstanceOf[util.ArrayList[String]].asScala.toSet.map(UserGroup),
AuthenticationTokenId(keycloakToken.getId),
keycloakToken.getRealmAccess.getRoles.asScala.toSet.map(UserAssumeRole)
keycloakToken.getRealmAccess.getRoles.asScala.toSet.map(UserAssumeRole),
isNPA(keycloakToken)
))
case Failure(exc: VerificationException) =>
logger.warn("Token (value={}) verification failed ex={}", token.value, exc.getMessage)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,10 @@ trait TokenGeneration {
AwsSecretKey(rand.alphanumeric.take(32).mkString)
)

protected[this] def generateAwsSession(duration: Option[Duration]): AwsSession = {
val tokenDuration = duration match {
case None => stsSettings.defaultTokenSessionDuration
case Some(durationRequested) =>
if (durationRequested > stsSettings.maxTokenSessionDuration) stsSettings.maxTokenSessionDuration
else durationRequested
}

protected[this] def generateAwsSession(duration: Duration): AwsSession = {
AwsSession(
sessionToken = AwsSessionToken(rand.alphanumeric.take(32).mkString),
expiration = AwsSessionTokenExpiration(Instant.now().plusMillis(tokenDuration.toMillis))
expiration = AwsSessionTokenExpiration(Instant.now().plusMillis(duration.toMillis))
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ trait UserTokenDbService extends LazyLogging with TokenGeneration {
* @param duration optional: the duration of the session, if duration is not given then it defaults to the application application default
* @return
*/
def getAwsCredentialWithToken(userName: Username, userGroups: Set[UserGroup], duration: Option[Duration]): Future[AwsCredentialWithToken] =
def getAwsCredentialWithToken(userName: Username, userGroups: Set[UserGroup], duration: Duration): Future[AwsCredentialWithToken] =
for {
(awsCredential, AccountStatus(isEnabled)) <- getOrGenerateAwsCredentialWithStatus(userName)
awsSession <- getNewAwsSession(userName, duration)
Expand All @@ -63,7 +63,7 @@ trait UserTokenDbService extends LazyLogging with TokenGeneration {
* @param duration optional: the duration of the session, if duration is not given then it defaults to the application application default
* @return
*/
def getAwsCredentialWithToken(userName: Username, userGroups: Set[UserGroup], role: UserAssumeRole, duration: Option[Duration]): Future[AwsCredentialWithToken] =
def getAwsCredentialWithToken(userName: Username, userGroups: Set[UserGroup], role: UserAssumeRole, duration: Duration): Future[AwsCredentialWithToken] =
for {
(awsCredential, AccountStatus(isEnabled)) <- getOrGenerateAwsCredentialWithStatus(userName)
awsSession <- getNewAwsSessionWithToken(userName, role, duration)
Expand Down Expand Up @@ -149,7 +149,7 @@ trait UserTokenDbService extends LazyLogging with TokenGeneration {
* @param generationTriesLeft Number of times to retry token generation, in case it collides
* @return
*/
private[this] def getNewAwsSession(userName: Username, duration: Option[Duration], generationTriesLeft: Int = 3): Future[AwsSession] = {
private[this] def getNewAwsSession(userName: Username, duration: Duration, generationTriesLeft: Int = 3): Future[AwsSession] = {
val newAwsSession = generateAwsSession(duration)
insertToken(newAwsSession.sessionToken, userName, newAwsSession.expiration)
.flatMap {
Expand All @@ -172,7 +172,7 @@ trait UserTokenDbService extends LazyLogging with TokenGeneration {
* @param generationTriesLeft Number of times to retry token generation, in case it collides
* @return
*/
private[this] def getNewAwsSessionWithToken(userName: Username, role: UserAssumeRole, duration: Option[Duration], generationTriesLeft: Int = 3): Future[AwsSession] = {
private[this] def getNewAwsSessionWithToken(userName: Username, role: UserAssumeRole, duration: Duration, generationTriesLeft: Int = 3): Future[AwsSession] = {
val newAwsSession = generateAwsSession(duration)
insertToken(newAwsSession.sessionToken, userName, role, newAwsSession.expiration)
.flatMap {
Expand Down
4 changes: 2 additions & 2 deletions src/test/scala/com/ing/wbaa/rokku/sts/api/AdminApiTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ class AdminApiTest extends AnyWordSpec

protected[this] def verifyAuthenticationToken(token: BearerToken): Option[AuthenticationUserInfo] =
token.value match {
case "valid" => Some(AuthenticationUserInfo(Username("username"), Set(UserGroup("admins"), UserGroup("group2")), AuthenticationTokenId("tokenOk"), Set.empty))
case "notAdmin" => Some(AuthenticationUserInfo(Username("username"), Set(UserGroup("group1"), UserGroup("group2")), AuthenticationTokenId("tokenOk"), Set.empty))
case "valid" => Some(AuthenticationUserInfo(Username("username"), Set(UserGroup("admins"), UserGroup("group2")), AuthenticationTokenId("tokenOk"), Set.empty, isNPA = false))
case "notAdmin" => Some(AuthenticationUserInfo(Username("username"), Set(UserGroup("group1"), UserGroup("group2")), AuthenticationTokenId("tokenOk"), Set.empty, isNPA = false))
case _ => None
}

Expand Down
6 changes: 3 additions & 3 deletions src/test/scala/com/ing/wbaa/rokku/sts/api/NpaApiTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ class NpaApiTest extends AnyWordSpec

protected[this] def verifyAuthenticationToken(token: BearerToken): Option[AuthenticationUserInfo] =
token.value match {
case "non-npa-user-token" => Some(AuthenticationUserInfo(Username(nonNpaUser), Set(), AuthenticationTokenId("nonNpaUser?"), Set()))
case "npa-user-token" => Some(AuthenticationUserInfo(Username(npaUser), Set(), AuthenticationTokenId("npaUser"), Set(UserAssumeRole(keycloakSettings.npaRole))))
case "npa-user-disabled-token" => Some(AuthenticationUserInfo(Username(disabledNpaUser), Set(), AuthenticationTokenId("disabledNpaUser"), Set(UserAssumeRole(keycloakSettings.npaRole))))
case "non-npa-user-token" => Some(AuthenticationUserInfo(Username(nonNpaUser), Set(), AuthenticationTokenId("nonNpaUser?"), Set(), isNPA = false))
case "npa-user-token" => Some(AuthenticationUserInfo(Username(npaUser), Set(), AuthenticationTokenId("npaUser"), Set(UserAssumeRole(keycloakSettings.npaRole)), isNPA = false))
case "npa-user-disabled-token" => Some(AuthenticationUserInfo(Username(disabledNpaUser), Set(), AuthenticationTokenId("disabledNpaUser"), Set(UserAssumeRole(keycloakSettings.npaRole)), isNPA = false))
case _ => None
}

Expand Down
Loading

0 comments on commit 9ca4237

Please sign in to comment.