diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/ImpersonationWarning.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/ImpersonationWarning.scala index b3bacf1c5c..cbeb962dc6 100644 --- a/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/ImpersonationWarning.scala +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/ImpersonationWarning.scala @@ -135,6 +135,12 @@ object ImpersonationWarning { implicit val jwtAuthRule: ImpersonationWarningExtractor[JwtAuthRule] = ImpersonationWarningExtractor[JwtAuthRule] { (rule, blockName, _) => Some(impersonationNotSupportedWarning(rule, blockName)) } + implicit val jwtAuthenticationRule: ImpersonationWarningExtractor[JwtAuthenticationRule] = ImpersonationWarningExtractor[JwtAuthenticationRule] { (rule, blockName, _) => + Some(impersonationNotSupportedWarning(rule, blockName)) + } + implicit val jwtAuthorizationRule: ImpersonationWarningExtractor[JwtAuthorizationRule] = ImpersonationWarningExtractor[JwtAuthorizationRule] { (rule, blockName, _) => + Some(impersonationNotSupportedWarning(rule, blockName)) + } implicit val ldapAuthenticationRule: ImpersonationWarningExtractor[LdapAuthenticationRule] = ImpersonationWarningExtractor[LdapAuthenticationRule] { (rule, blockName, requestId) => ldapWarning(rule.name, blockName, rule.settings.ldap.id, rule.impersonation)(requestId) } diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtAuthRule.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtAuthRule.scala index 803ff69b99..3f392623dd 100644 --- a/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtAuthRule.scala +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtAuthRule.scala @@ -16,246 +16,27 @@ */ package tech.beshu.ror.accesscontrol.blocks.rules.auth -import io.jsonwebtoken.Jwts -import io.jsonwebtoken.security.Keys -import monix.eval.Task -import org.apache.logging.log4j.scala.Logging -import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef -import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef.SignatureCheckMethod.* import tech.beshu.ror.accesscontrol.blocks.rules.Rule import tech.beshu.ror.accesscontrol.blocks.rules.Rule.AuthenticationRule.EligibleUsersSupport -import tech.beshu.ror.accesscontrol.blocks.rules.Rule.RuleResult.{Fulfilled, Rejected} -import tech.beshu.ror.accesscontrol.blocks.rules.Rule.{AuthRule, RuleName, RuleResult} -import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthRule.Groups -import tech.beshu.ror.accesscontrol.blocks.rules.auth.base.impersonation.{AuthenticationImpersonationCustomSupport, AuthorizationImpersonationCustomSupport} -import tech.beshu.ror.accesscontrol.blocks.{BlockContext, BlockContextUpdater} -import tech.beshu.ror.accesscontrol.domain.LoggedUser.DirectlyLoggedUser +import tech.beshu.ror.accesscontrol.blocks.rules.Rule.RuleName +import tech.beshu.ror.accesscontrol.blocks.rules.auth.base.BaseComposedAuthenticationAndAuthorizationRule import tech.beshu.ror.accesscontrol.domain.* -import tech.beshu.ror.accesscontrol.request.RequestContext -import tech.beshu.ror.accesscontrol.request.RequestContextOps.* -import tech.beshu.ror.accesscontrol.utils.ClaimsOps.* -import tech.beshu.ror.accesscontrol.utils.ClaimsOps.ClaimSearchResult.{Found, NotFound} -import tech.beshu.ror.implicits.* -import tech.beshu.ror.utils.RefinedUtils.* -import tech.beshu.ror.utils.uniquelist.{UniqueList, UniqueNonEmptyList} -import scala.util.Try - -final class JwtAuthRule(val settings: JwtAuthRule.Settings, - override val userIdCaseSensitivity: CaseSensitivity) - extends AuthRule - with AuthenticationImpersonationCustomSupport - with AuthorizationImpersonationCustomSupport - with Logging { +final class JwtAuthRule(val authentication: JwtAuthenticationRule, + val authorization: JwtAuthorizationRule | JwtPseudoAuthorizationRule) + extends BaseComposedAuthenticationAndAuthorizationRule( + authenticationRule = authentication.withDisabledCallsToExternalAuthenticationService, + authorizationRule = authorization + ) { override val name: Rule.Name = JwtAuthRule.Name.name override val eligibleUsers: EligibleUsersSupport = EligibleUsersSupport.NotAvailable - - private val parser = - settings.jwt.checkMethod match { - case NoCheck(_) => Jwts.parser().unsecured().build() - case Hmac(rawKey) => Jwts.parser().verifyWith(Keys.hmacShaKeyFor(rawKey)).build() - case Rsa(pubKey) => Jwts.parser().verifyWith(pubKey).build() - case Ec(pubKey) => Jwts.parser().verifyWith(pubKey).build() - } - - override protected[rules] def authenticate[B <: BlockContext : BlockContextUpdater](blockContext: B): Task[RuleResult[B]] = - Task.now(RuleResult.Fulfilled(blockContext)) - - override protected[rules] def authorize[B <: BlockContext : BlockContextUpdater](blockContext: B): Task[RuleResult[B]] = - Task - .unit - .flatMap { _ => - settings.permittedGroups match { - case Groups.NotDefined => - authorizeUsingJwtToken(blockContext) - case Groups.Defined(groupsLogic) if blockContext.isCurrentGroupPotentiallyEligible(groupsLogic) => - authorizeUsingJwtToken(blockContext) - case Groups.Defined(_) => - Task.now(RuleResult.Rejected()) - } - } - - private def authorizeUsingJwtToken[B <: BlockContext : BlockContextUpdater](blockContext: B): Task[RuleResult[B]] = { - jwtTokenFrom(blockContext.requestContext) match { - case None => - logger.debug(s"[${blockContext.requestContext.id.show}] Authorization header '${settings.jwt.authorizationTokenDef.headerName.show}' is missing or does not contain a JWT token") - Task.now(Rejected()) - case Some(token) => - process(token, blockContext) - } - } - - private def jwtTokenFrom(requestContext: RequestContext) = { - requestContext - .authorizationToken(settings.jwt.authorizationTokenDef) - .map(t => Jwt.Token(t.value)) - } - - private def process[B <: BlockContext : BlockContextUpdater](token: Jwt.Token, - blockContext: B): Task[RuleResult[B]] = { - implicit val requestId: RequestId = blockContext.requestContext.id.toRequestId - userAndGroupsFromJwtToken(token) match { - case Left(_) => - Task.now(Rejected()) - case Right((tokenPayload, user, groups)) => - if (logger.delegate.isDebugEnabled) { - logClaimSearchResults(user, groups)(blockContext.requestContext.id.toRequestId) - } - val claimProcessingResult = for { - newBlockContext <- handleUserClaimSearchResult(blockContext, user) - finalBlockContext <- handleGroupsClaimSearchResult(newBlockContext, groups) - } yield finalBlockContext.withUserMetadata(_.withJwtToken(tokenPayload)) - claimProcessingResult match { - case Left(_) => - Task.now(Rejected()) - case Right(modifiedBlockContext) => - settings.jwt.checkMethod match { - case NoCheck(service) => - implicit val requestId: RequestId = blockContext.requestContext.id.toRequestId - service - .authenticate(Credentials(User.Id(nes("jwt")), PlainTextSecret(token.value))) - .map(RuleResult.resultBasedOnCondition(modifiedBlockContext)(_)) - case Hmac(_) | Rsa(_) | Ec(_) => - Task.now(Fulfilled(modifiedBlockContext)) - } - } - } - } - - private def logClaimSearchResults(user: Option[ClaimSearchResult[User.Id]], - groups: Option[ClaimSearchResult[UniqueList[Group]]]) - (implicit requestId: RequestId): Unit = { - (settings.jwt.userClaim, user) match { - case (Some(userClaim), Some(u)) => - logger.debug(s"[${requestId.show}] JWT resolved user for claim ${userClaim.name.rawPath}: ${u.show}") - case _ => - } - (settings.jwt.groupsConfig, groups) match { - case (Some(groupsConfig), Some(g)) => - val claimsDescription = groupsConfig.namesClaim match { - case Some(namesClaim) => s"claims (id:'${groupsConfig.idsClaim.name.show}',name:'${namesClaim.name.show}')" - case None => s"claim '${groupsConfig.idsClaim.name.show}'" - } - logger.debug(s"[${requestId.show}] JWT resolved groups for ${claimsDescription.show}: ${g.show}") - case _ => - } - } - - private def userAndGroupsFromJwtToken(token: Jwt.Token) - (implicit requestId: RequestId) = { - claimsFrom(token).map { decodedJwtToken => - (decodedJwtToken, userIdFrom(decodedJwtToken), groupsFrom(decodedJwtToken)) - } - } - - private def logBadToken(ex: Throwable, token: Jwt.Token) - (implicit requestId: RequestId): Unit = { - val tokenParts = token.show.split("\\.") - val printableToken = if (!logger.delegate.isDebugEnabled && tokenParts.length === 3) { - // signed JWT, last block is the cryptographic digest, which should be treated as a secret. - s"${tokenParts(0)}.${tokenParts(1)} (omitted digest)" - } - else { - token.show - } - logger.debug(s"[${requestId.show}] JWT token '${printableToken.show}' parsing error: ${ex.getClass.getSimpleName.show} ${ex.getMessage.show}") - } - - private def claimsFrom(token: Jwt.Token) - (implicit requestId: RequestId) = { - settings.jwt.checkMethod match { - case NoCheck(_) => - token.value.value.split("\\.").toList match { - case fst :: snd :: _ => - Try(parser.parseUnsecuredClaims(s"$fst.$snd.").getPayload) - .toEither - .map(Jwt.Payload.apply) - .left.map { ex => logBadToken(ex, token) } - case _ => - Left(()) - } - case Hmac(_) | Rsa(_) | Ec(_) => - Try(parser.parseSignedClaims(token.value.value).getPayload) - .toEither - .map(Jwt.Payload.apply) - .left.map { ex => logBadToken(ex, token) } - } - } - - private def userIdFrom(payload: Jwt.Payload) = { - settings.jwt.userClaim.map(payload.claims.userIdClaim) - } - - private def groupsFrom(payload: Jwt.Payload) = { - settings.jwt.groupsConfig.map(groupsConfig => - payload.claims.groupsClaim(groupsConfig.idsClaim, groupsConfig.namesClaim) - ) - } - - private def handleUserClaimSearchResult[B <: BlockContext : BlockContextUpdater](blockContext: B, - result: Option[ClaimSearchResult[User.Id]]) = { - result match { - case None => Right(blockContext) - case Some(Found(userId)) => Right(blockContext.withUserMetadata(_.withLoggedUser(DirectlyLoggedUser(userId)))) - case Some(NotFound) => Left(()) - } - } - - private def handleGroupsClaimSearchResult[B <: BlockContext : BlockContextUpdater](blockContext: B, - result: Option[ClaimSearchResult[UniqueList[Group]]]) = { - (result, settings.permittedGroups) match { - case (None, Groups.Defined(_)) => - Left(()) - case (None, Groups.NotDefined) => - Right(blockContext) - case (Some(NotFound), Groups.Defined(_)) => - Left(()) - case (Some(NotFound), Groups.NotDefined) => - Right(blockContext) // if groups field is not found, we treat this situation as same as empty groups would be passed - case (Some(Found(groups)), Groups.Defined(groupsLogic)) => - UniqueNonEmptyList.from(groups) match { - case Some(nonEmptyGroups) => - groupsLogic.availableGroupsFrom(nonEmptyGroups) match { - case Some(matchedGroups) => - checkIfCanContinueWithGroups(blockContext, UniqueList.from(matchedGroups)) - .map(_.withUserMetadata(_.addAvailableGroups(matchedGroups))) - case None => - Left(()) - } - case None => - Left(()) - } - case (Some(Found(groups)), Groups.NotDefined) => - checkIfCanContinueWithGroups(blockContext, groups) - } - } - - private def checkIfCanContinueWithGroups[B <: BlockContext](blockContext: B, - groups: UniqueList[Group]) = { - UniqueNonEmptyList.from(groups.toList.map(_.id)) match { - case Some(nonEmptyGroups) if blockContext.isCurrentGroupEligible(GroupIds(nonEmptyGroups)) => - Right(blockContext) - case Some(_) | None => - Left(()) - } - } + override val userIdCaseSensitivity: CaseSensitivity = authentication.userIdCaseSensitivity } object JwtAuthRule { - implicit case object Name extends RuleName[JwtAuthRule] { override val name = Rule.Name("jwt_auth") } - - final case class Settings(jwt: JwtDef, permittedGroups: Groups) - - sealed trait Groups - - object Groups { - case object NotDefined extends Groups - - final case class Defined(groupsLogic: GroupsLogic) extends Groups - } } \ No newline at end of file diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtAuthenticationRule.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtAuthenticationRule.scala new file mode 100644 index 0000000000..9510d6cda7 --- /dev/null +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtAuthenticationRule.scala @@ -0,0 +1,72 @@ +/* + * This file is part of ReadonlyREST. + * + * ReadonlyREST is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ReadonlyREST is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ReadonlyREST. If not, see http://www.gnu.org/licenses/ + */ +package tech.beshu.ror.accesscontrol.blocks.rules.auth + +import monix.eval.Task +import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef +import tech.beshu.ror.accesscontrol.blocks.rules.Rule +import tech.beshu.ror.accesscontrol.blocks.rules.Rule.AuthenticationRule.EligibleUsersSupport +import tech.beshu.ror.accesscontrol.blocks.rules.Rule.{AuthenticationRule, RuleName, RuleResult} +import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthenticationRule.Settings +import tech.beshu.ror.accesscontrol.blocks.rules.auth.base.BaseJwtRule +import tech.beshu.ror.accesscontrol.blocks.rules.auth.base.impersonation.AuthenticationImpersonationCustomSupport +import tech.beshu.ror.accesscontrol.blocks.{BlockContext, BlockContextUpdater} +import tech.beshu.ror.accesscontrol.domain.* +import tech.beshu.ror.accesscontrol.domain.LoggedUser.DirectlyLoggedUser +import tech.beshu.ror.accesscontrol.utils.ClaimsOps.ClaimSearchResult +import tech.beshu.ror.accesscontrol.utils.ClaimsOps.ClaimSearchResult.{Found, NotFound} + +final class JwtAuthenticationRule(val settings: Settings, + override val userIdCaseSensitivity: CaseSensitivity, + disabledCallsToExternalAuthenticationService: Boolean = false) + extends AuthenticationRule + with AuthenticationImpersonationCustomSupport + with BaseJwtRule { + + override val name: Rule.Name = JwtAuthenticationRule.Name.name + + override val eligibleUsers: EligibleUsersSupport = EligibleUsersSupport.NotAvailable + + override protected[rules] def authenticate[B <: BlockContext : BlockContextUpdater](blockContext: B): Task[RuleResult[B]] = { + processUsingJwtToken(blockContext, settings.jwt, disabledCallsToExternalAuthenticationService) { tokenData => + authenticate(blockContext, tokenData.userId, tokenData.payload) + } + } + + private def authenticate[B <: BlockContext : BlockContextUpdater](blockContext: B, + result: Option[ClaimSearchResult[User.Id]], + payload: Jwt.Payload) = { + (result match { + case None => Right(blockContext) + case Some(NotFound) => Left(()) + case Some(Found(userId)) => Right(blockContext.withUserMetadata(_.withLoggedUser(DirectlyLoggedUser(userId)))) + }).map(_.withUserMetadata(_.withJwtToken(payload))) + } + + def withDisabledCallsToExternalAuthenticationService = + new JwtAuthenticationRule(settings, userIdCaseSensitivity, disabledCallsToExternalAuthenticationService = true) + +} + +object JwtAuthenticationRule { + + implicit case object Name extends RuleName[JwtAuthenticationRule] { + override val name = Rule.Name("jwt_authentication") + } + + final case class Settings(jwt: JwtDef) +} diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtAuthorizationRule.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtAuthorizationRule.scala new file mode 100644 index 0000000000..9d405039a4 --- /dev/null +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtAuthorizationRule.scala @@ -0,0 +1,74 @@ +/* + * This file is part of ReadonlyREST. + * + * ReadonlyREST is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ReadonlyREST is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ReadonlyREST. If not, see http://www.gnu.org/licenses/ + */ +package tech.beshu.ror.accesscontrol.blocks.rules.auth + +import monix.eval.Task +import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef +import tech.beshu.ror.accesscontrol.blocks.rules.Rule +import tech.beshu.ror.accesscontrol.blocks.rules.Rule.{AuthorizationRule, RuleName, RuleResult} +import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthorizationRule.Settings +import tech.beshu.ror.accesscontrol.blocks.rules.auth.base.BaseJwtRule +import tech.beshu.ror.accesscontrol.blocks.rules.auth.base.impersonation.AuthorizationImpersonationCustomSupport +import tech.beshu.ror.accesscontrol.blocks.{BlockContext, BlockContextUpdater} +import tech.beshu.ror.accesscontrol.domain.{Group, GroupIds, GroupsLogic} +import tech.beshu.ror.accesscontrol.utils.ClaimsOps.ClaimSearchResult +import tech.beshu.ror.accesscontrol.utils.ClaimsOps.ClaimSearchResult.* +import tech.beshu.ror.utils.uniquelist.{UniqueList, UniqueNonEmptyList} + +final class JwtAuthorizationRule(val settings: Settings) + extends AuthorizationRule + with AuthorizationImpersonationCustomSupport + with BaseJwtRule { + + override val name: Rule.Name = JwtAuthorizationRule.Name.name + + override protected[rules] def authorize[B <: BlockContext : BlockContextUpdater](blockContext: B): Task[RuleResult[B]] = { + settings.groupsLogic match { + case groupsLogic if blockContext.isCurrentGroupPotentiallyEligible(groupsLogic) => + processUsingJwtToken(blockContext, settings.jwt) { tokenData => + authorize(blockContext, tokenData.groups, groupsLogic) + } + case _ => + Task.now(RuleResult.Rejected()) + } + } + + private def authorize[B <: BlockContext : BlockContextUpdater](blockContext: B, + result: Option[ClaimSearchResult[UniqueList[Group]]], + groupsLogic: GroupsLogic) = { + result match { + case None | Some(NotFound) => + Left(()) + case Some(Found(groups)) => + (for { + nonEmptyGroups <- UniqueNonEmptyList.from(groups) + matchedGroups <- groupsLogic.availableGroupsFrom(nonEmptyGroups) + if blockContext.isCurrentGroupEligible(GroupIds.from(matchedGroups)) + } yield blockContext.withUserMetadata(_.addAvailableGroups(matchedGroups))).toRight(()) + } + } + +} + +object JwtAuthorizationRule { + + implicit case object Name extends RuleName[JwtAuthorizationRule] { + override val name = Rule.Name("jwt_authorization") + } + + final case class Settings(jwt: JwtDef, groupsLogic: GroupsLogic) +} diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtPseudoAuthorizationRule.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtPseudoAuthorizationRule.scala new file mode 100644 index 0000000000..8153029e39 --- /dev/null +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/JwtPseudoAuthorizationRule.scala @@ -0,0 +1,64 @@ +/* + * This file is part of ReadonlyREST. + * + * ReadonlyREST is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ReadonlyREST is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ReadonlyREST. If not, see http://www.gnu.org/licenses/ + */ +package tech.beshu.ror.accesscontrol.blocks.rules.auth + +import monix.eval.Task +import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef +import tech.beshu.ror.accesscontrol.blocks.rules.Rule +import tech.beshu.ror.accesscontrol.blocks.rules.Rule.{AuthorizationRule, RuleResult} +import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtPseudoAuthorizationRule.Settings +import tech.beshu.ror.accesscontrol.blocks.rules.auth.base.BaseJwtRule +import tech.beshu.ror.accesscontrol.blocks.rules.auth.base.impersonation.AuthorizationImpersonationCustomSupport +import tech.beshu.ror.accesscontrol.blocks.{BlockContext, BlockContextUpdater} +import tech.beshu.ror.accesscontrol.domain.{Group, GroupIds} +import tech.beshu.ror.accesscontrol.utils.ClaimsOps.ClaimSearchResult +import tech.beshu.ror.accesscontrol.utils.ClaimsOps.ClaimSearchResult.* +import tech.beshu.ror.utils.uniquelist.{UniqueList, UniqueNonEmptyList} + +// Pseudo-authorization rule should be used exclusively as part of the JwtAuthRule, when there are is no groups logic defined. +// It preserves the kbn_auth rule behavior from before introducing separate authn and authz rules. +final class JwtPseudoAuthorizationRule(val settings: Settings) + extends AuthorizationRule + with AuthorizationImpersonationCustomSupport + with BaseJwtRule { + + override val name: Rule.Name = JwtAuthorizationRule.Name.name + + override protected[rules] def authorize[B <: BlockContext : BlockContextUpdater](blockContext: B): Task[RuleResult[B]] = { + processUsingJwtToken(blockContext, settings.jwt) { tokenData => + pseudoAuthorize(blockContext, tokenData.groups) + } + } + + private def pseudoAuthorize[B <: BlockContext](blockContext: B, + result: Option[ClaimSearchResult[UniqueList[Group]]]) = { + result match { + case None | Some(NotFound) => + Right(blockContext) + case Some(Found(groups)) => + (for { + nonEmptyGroups <- UniqueNonEmptyList.from(groups) + if blockContext.isCurrentGroupEligible(GroupIds.from(nonEmptyGroups)) + } yield blockContext).toRight(()) + } + } + +} + +object JwtPseudoAuthorizationRule { + final case class Settings(jwt: JwtDef) +} diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/base/BaseJwtRule.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/base/BaseJwtRule.scala new file mode 100644 index 0000000000..a502737137 --- /dev/null +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/rules/auth/base/BaseJwtRule.scala @@ -0,0 +1,169 @@ +/* + * This file is part of ReadonlyREST. + * + * ReadonlyREST is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ReadonlyREST is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ReadonlyREST. If not, see http://www.gnu.org/licenses/ + */ +package tech.beshu.ror.accesscontrol.blocks.rules.auth.base + +import cats.implicits.toShow +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import monix.eval.Task +import org.apache.logging.log4j.scala.Logging +import tech.beshu.ror.accesscontrol.blocks.BlockContext +import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef +import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef.SignatureCheckMethod.{Ec, Hmac, NoCheck, Rsa} +import tech.beshu.ror.accesscontrol.blocks.rules.Rule.RuleResult +import tech.beshu.ror.accesscontrol.blocks.rules.Rule.RuleResult.{Fulfilled, Rejected} +import tech.beshu.ror.accesscontrol.blocks.rules.auth.base.BaseJwtRule.* +import tech.beshu.ror.accesscontrol.domain.* +import tech.beshu.ror.accesscontrol.request.RequestContext +import tech.beshu.ror.accesscontrol.request.RequestContextOps.from +import tech.beshu.ror.accesscontrol.utils.ClaimsOps.* +import tech.beshu.ror.implicits.* +import tech.beshu.ror.utils.RefinedUtils.nes +import tech.beshu.ror.utils.uniquelist.UniqueList + +import scala.util.Try + +trait BaseJwtRule extends Logging { + + protected def processUsingJwtToken[B <: BlockContext](blockContext: B, + jwt: JwtDef, + disabledCallsToExternalAuthenticationService: Boolean = false) + (operation: JwtData => Either[Unit, B]): Task[RuleResult[B]] = { + implicit val jwtImpl: JwtDef = jwt + jwtTokenFrom(blockContext.requestContext) match { + case None => + logger.debug(s"[${blockContext.requestContext.id.show}] Authorization header '${jwt.authorizationTokenDef.headerName.show}' is missing or does not contain a JWT token") + Task.now(Rejected()) + case Some(token) => + implicit val requestId: RequestId = blockContext.requestContext.id.toRequestId + userAndGroupsFromJwtToken(token) match { + case Left(_) => + Task.now(Rejected()) + case Right(jwtData) => + if (logger.delegate.isDebugEnabled) { + logClaimSearchResults(jwtData.userId, jwtData.groups) + } + val claimProcessingResult = operation(jwtData) + claimProcessingResult match { + case Left(_) => + Task.now(Rejected()) + case Right(modifiedBlockContext) => + jwt.checkMethod match { + case NoCheck(service) if !disabledCallsToExternalAuthenticationService => + service + .authenticate(Credentials(User.Id(nes("jwt")), PlainTextSecret(token.value))) + .map(RuleResult.resultBasedOnCondition(modifiedBlockContext)(_)) + case _ => + Task.now(Fulfilled(modifiedBlockContext)) + } + } + } + } + } + + private def jwtTokenFrom(requestContext: RequestContext)(implicit jwt: JwtDef) = { + requestContext + .authorizationToken(jwt.authorizationTokenDef) + .map(t => Jwt.Token(t.value)) + } + + private def logClaimSearchResults(user: Option[ClaimSearchResult[User.Id]], + groups: Option[ClaimSearchResult[UniqueList[Group]]]) + (implicit requestId: RequestId, jwt: JwtDef): Unit = { + (jwt.userClaim, user) match { + case (Some(userClaim), Some(u)) => + logger.debug(s"[${requestId.show}] JWT resolved user for claim ${userClaim.name.rawPath}: ${u.show}") + case _ => + } + (jwt.groupsConfig, groups) match { + case (Some(groupsConfig), Some(g)) => + val claimsDescription = groupsConfig.namesClaim match { + case Some(namesClaim) => s"claims (id:'${groupsConfig.idsClaim.name.show}',name:'${namesClaim.name.show}')" + case None => s"claim '${groupsConfig.idsClaim.name.show}'" + } + logger.debug(s"[${requestId.show}] JWT resolved groups for ${claimsDescription.show}: ${g.show}") + case _ => + } + } + + private def userAndGroupsFromJwtToken(token: Jwt.Token) + (implicit requestId: RequestId, + jwt: JwtDef): Either[Unit, JwtData] = { + claimsFrom(token).map { decodedJwtToken => + JwtData(decodedJwtToken, userIdFrom(decodedJwtToken), groupsFrom(decodedJwtToken)) + } + } + + private def logBadToken(ex: Throwable, token: Jwt.Token) + (implicit requestId: RequestId): Unit = { + val tokenParts = token.show.split("\\.") + val printableToken = if (!logger.delegate.isDebugEnabled && tokenParts.length === 3) { + // signed JWT, last block is the cryptographic digest, which should be treated as a secret. + s"${tokenParts(0)}.${tokenParts(1)} (omitted digest)" + } + else { + token.show + } + logger.debug(s"[${requestId.show}] JWT token '${printableToken.show}' parsing error: ${ex.getClass.getSimpleName.show} ${ex.getMessage.show}") + } + + private def claimsFrom(token: Jwt.Token) + (implicit requestId: RequestId, + jwt: JwtDef) = { + val parser = jwt.checkMethod match { + case NoCheck(_) => Jwts.parser().unsecured().build() + case Hmac(rawKey) => Jwts.parser().verifyWith(Keys.hmacShaKeyFor(rawKey)).build() + case Rsa(pubKey) => Jwts.parser().verifyWith(pubKey).build() + case Ec(pubKey) => Jwts.parser().verifyWith(pubKey).build() + } + jwt.checkMethod match { + case NoCheck(_) => + token.value.value.split("\\.").toList match { + case fst :: snd :: _ => + Try(parser.parseUnsecuredClaims(s"$fst.$snd.").getPayload) + .toEither + .map(Jwt.Payload.apply) + .left.map { ex => logBadToken(ex, token) } + case _ => + Left(()) + } + case Hmac(_) | Rsa(_) | Ec(_) => + Try(parser.parseSignedClaims(token.value.value).getPayload) + .toEither + .map(Jwt.Payload.apply) + .left.map { ex => logBadToken(ex, token) } + } + } + + private def userIdFrom(payload: Jwt.Payload)(implicit jwt: JwtDef): Option[ClaimSearchResult[User.Id]] = { + jwt.userClaim.map(payload.claims.userIdClaim) + } + + private def groupsFrom(payload: Jwt.Payload)(implicit jwt: JwtDef): Option[ClaimSearchResult[UniqueList[Group]]] = { + jwt.groupsConfig.map(groupsConfig => + payload.claims.groupsClaim(groupsConfig.idsClaim, groupsConfig.namesClaim) + ) + } +} + +object BaseJwtRule { + + protected final case class JwtData(payload: Jwt.Payload, + userId: Option[ClaimSearchResult[User.Id]], + groups: Option[ClaimSearchResult[UniqueList[Group]]]) + +} diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/users/LocalUsersContext.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/users/LocalUsersContext.scala index c5f9d26671..dd68b8b1ec 100644 --- a/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/users/LocalUsersContext.scala +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/users/LocalUsersContext.scala @@ -61,6 +61,8 @@ object LocalUsersContext { implicit val hostsRule: LocalUsersSupport[HostsRule] = NotAvailableLocalUsers() implicit val indicesRule: LocalUsersSupport[IndicesRule] = NotAvailableLocalUsers() implicit val jwtAuthRule: LocalUsersSupport[JwtAuthRule] = NotAvailableLocalUsers() + implicit val jwtAuthenticationRule: LocalUsersSupport[JwtAuthenticationRule] = NotAvailableLocalUsers() + implicit val jwtAuthorizationRule: LocalUsersSupport[JwtAuthorizationRule] = NotAvailableLocalUsers() implicit val kibanaUserDataRule: LocalUsersSupport[KibanaUserDataRule] = NotAvailableLocalUsers() implicit val kibanaAccessRule: LocalUsersSupport[KibanaAccessRule] = NotAvailableLocalUsers() implicit val kibanaHideAppsRule: LocalUsersSupport[KibanaHideAppsRule] = NotAvailableLocalUsers() diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/variables/runtime/VariableContext.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/variables/runtime/VariableContext.scala index dd21e2f995..f26455a01f 100644 --- a/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/variables/runtime/VariableContext.scala +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/blocks/variables/runtime/VariableContext.scala @@ -86,6 +86,8 @@ object VariableContext { implicit val headersAndRule: VariableUsage[HeadersAndRule] = NotUsingVariable() implicit val headersOrRule: VariableUsage[HeadersOrRule] = NotUsingVariable() implicit val jwtAuthRule: VariableUsage[JwtAuthRule] = NotUsingVariable() + implicit val jwtAuthenticationRule: VariableUsage[JwtAuthenticationRule] = NotUsingVariable() + implicit val jwtAuthorizationRule: VariableUsage[JwtAuthorizationRule] = NotUsingVariable() implicit val kibanaHideAppsRule: VariableUsage[KibanaHideAppsRule] = NotUsingVariable() implicit val ldapAuthenticationRule: VariableUsage[LdapAuthenticationRule] = NotUsingVariable() implicit val ldapAuthorizationRule: VariableUsage[LdapAuthorizationRule] = NotUsingVariable() diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/definitions/DefinitionsPack.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/definitions/DefinitionsPack.scala index 9499dffba2..ef7430eaf2 100644 --- a/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/definitions/DefinitionsPack.scala +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/definitions/DefinitionsPack.scala @@ -19,6 +19,7 @@ package tech.beshu.ror.accesscontrol.factory.decoders.definitions import cats.Show import tech.beshu.ror.accesscontrol.blocks.definitions.* import tech.beshu.ror.accesscontrol.blocks.definitions.ldap.LdapService +import tech.beshu.ror.accesscontrol.factory.decoders.definitions.Definitions.Item final case class DefinitionsPack(proxies: Definitions[ProxyAuth], users: Definitions[UserDef], @@ -30,9 +31,8 @@ final case class DefinitionsPack(proxies: Definitions[ProxyAuth], impersonators: Definitions[ImpersonatorDef], variableTransformationAliases: Definitions[VariableTransformationAliasDef]) -final case class Definitions[Item](items: List[Item]) extends AnyVal +final case class Definitions[ITEM <: Item](items: List[ITEM]) extends AnyVal object Definitions { - trait Item { type Id def id: Id diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/definitions/UsersDefinitionsDecoder.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/definitions/UsersDefinitionsDecoder.scala index 8925882778..12ca108572 100644 --- a/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/definitions/UsersDefinitionsDecoder.scala +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/definitions/UsersDefinitionsDecoder.scala @@ -256,7 +256,7 @@ object UsersDefinitionsDecoder { val localGroup: String = "local_group" val externalGroups: String = "external_group_ids" - val simpleMappingRequiredKeys: Set[String] = Set(localGroup) + val simpleMappingRequiredKeys: Set[String] = Set(id, name) val advancedMappingRequiredKeys: Set[String] = Set(localGroup, externalGroups) } diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/ruleDecoders.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/ruleDecoders.scala index 2fe4cdf6c3..36042b2ae2 100644 --- a/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/ruleDecoders.scala +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/ruleDecoders.scala @@ -132,17 +132,21 @@ object ruleDecoders { case ExternalAuthorizationRule.Name.name => Some(new ExternalAuthorizationRuleDecoder(authorizationServiceDefinitions, impersonatorsDefinitions, mocksProvider, globalSettings)) case JwtAuthRule.Name.name => - Some(new JwtAuthRuleDecoder(jwtDefinitions, globalSettings)) + Some(new JwtAuthRulesDecoders.AuthRuleDecoder(jwtDefinitions, globalSettings)) + case JwtAuthenticationRule.Name.name => + Some(new JwtAuthRulesDecoders.AuthenticationRuleDecoder(jwtDefinitions, globalSettings)) + case JwtAuthorizationRule.Name.name => + Some(new JwtAuthRulesDecoders.AuthorizationRuleDecoder(jwtDefinitions)) case LdapAuthorizationRule.Name.name => Some(new LdapAuthorizationRuleDecoder(ldapServiceDefinitions, impersonatorsDefinitions, mocksProvider, globalSettings)) case LdapAuthRule.Name.name => Some(new LdapAuthRuleDecoder(ldapServiceDefinitions, impersonatorsDefinitions, mocksProvider, globalSettings)) case RorKbnAuthRule.Name.name => - Some(new RorKbnAuthRuleDecoder(rorKbnDefinitions, globalSettings)) + Some(new RorKbnRulesDecoders.AuthRuleDecoder(rorKbnDefinitions, globalSettings)) case RorKbnAuthenticationRule.Name.name => - Some(new RorKbnAuthenticationRuleDecoder(rorKbnDefinitions, globalSettings)) + Some(new RorKbnRulesDecoders.AuthenticationRuleDecoder(rorKbnDefinitions, globalSettings)) case RorKbnAuthorizationRule.Name.name => - Some(new RorKbnAuthorizationRuleDecoder(rorKbnDefinitions)) + Some(new RorKbnRulesDecoders.AuthorizationRuleDecoder(rorKbnDefinitions)) case _ => authenticationRuleDecoderBy( name, diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/JwtAuthRuleDecoder.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/JwtAuthRuleDecoder.scala deleted file mode 100644 index 35d45c592e..0000000000 --- a/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/JwtAuthRuleDecoder.scala +++ /dev/null @@ -1,89 +0,0 @@ -/* - * This file is part of ReadonlyREST. - * - * ReadonlyREST is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * ReadonlyREST is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with ReadonlyREST. If not, see http://www.gnu.org/licenses/ - */ -package tech.beshu.ror.accesscontrol.factory.decoders.rules.auth - -import io.circe.Decoder -import tech.beshu.ror.accesscontrol.blocks.Block.RuleDefinition -import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef -import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthRule -import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthRule.Groups -import tech.beshu.ror.accesscontrol.factory.GlobalSettings -import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.Reason.Message -import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.RulesLevelCreationError -import tech.beshu.ror.accesscontrol.factory.decoders.definitions.Definitions -import tech.beshu.ror.accesscontrol.factory.decoders.definitions.JwtDefinitionsDecoder.* -import tech.beshu.ror.accesscontrol.factory.decoders.rules.RuleBaseDecoder.RuleBaseDecoderWithoutAssociatedFields -import tech.beshu.ror.accesscontrol.factory.decoders.rules.auth.groups.GroupsLogicRepresentationDecoder.GroupsLogicDecodingResult -import tech.beshu.ror.accesscontrol.factory.decoders.rules.auth.groups.GroupsLogicDecoder -import tech.beshu.ror.accesscontrol.utils.CirceOps.* -import tech.beshu.ror.implicits.* - -class JwtAuthRuleDecoder(jwtDefinitions: Definitions[JwtDef], - globalSettings: GlobalSettings) - extends RuleBaseDecoderWithoutAssociatedFields[JwtAuthRule] { - - override protected def decoder: Decoder[RuleDefinition[JwtAuthRule]] = { - JwtAuthRuleDecoder.nameAndGroupsSimpleDecoder - .or(JwtAuthRuleDecoder.nameAndGroupsExtendedDecoder) - .toSyncDecoder - .emapE { case (name, groupsLogic) => - jwtDefinitions.items.find(_.id === name) match { - case Some(jwtDef) => Right(JwtAuthRule.Settings(jwtDef, groupsLogic)) - case None => Left(RulesLevelCreationError(Message(s"Cannot find JWT definition with name: ${name.show}"))) - } - } - .map { settings => - RuleDefinition.create(new JwtAuthRule(settings, globalSettings.userIdCaseSensitivity)) - } - .decoder - } -} - -private object JwtAuthRuleDecoder { - - private val nameAndGroupsSimpleDecoder: Decoder[(JwtDef.Name, Groups)] = - DecoderHelpers - .decodeStringLikeNonEmpty - .map(JwtDef.Name.apply) - .map((_, Groups.NotDefined)) - - private val nameAndGroupsExtendedDecoder: Decoder[(JwtDef.Name, Groups)] = - Decoder - .instance { c => - for { - rorKbnDefName <- c.downField("name").as[JwtDef.Name] - groupsLogicDecodingResult <- GroupsLogicDecoder.decoder[JwtAuthRule].apply(c) - } yield (rorKbnDefName, groupsLogicDecodingResult) - } - .toSyncDecoder - .emapE { - case (name, groupsLogicDecodingResult) => - groupsLogicDecodingResult match { - case GroupsLogicDecodingResult.Success(groupsLogic) => - Right((name, Groups.Defined(groupsLogic))) - case GroupsLogicDecodingResult.GroupsLogicNotDefined(_) => - Right((name, Groups.NotDefined: Groups)) - case GroupsLogicDecodingResult.MultipleGroupsLogicsDefined(_, fields) => - val fieldsStr = fields.map(f => s"'$f'").mkString(" or ") - Left(RulesLevelCreationError(Message( - s"Please specify either $fieldsStr for JWT authorization rule '${name.show}'" - ))) - } - } - .decoder - -} \ No newline at end of file diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/JwtAuthRulesDecoders.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/JwtAuthRulesDecoders.scala new file mode 100644 index 0000000000..2c0aeab65f --- /dev/null +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/JwtAuthRulesDecoders.scala @@ -0,0 +1,54 @@ +/* + * This file is part of ReadonlyREST. + * + * ReadonlyREST is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ReadonlyREST is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ReadonlyREST. If not, see http://www.gnu.org/licenses/ + */ +package tech.beshu.ror.accesscontrol.factory.decoders.rules.auth + +import org.apache.logging.log4j.scala.Logging +import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef +import tech.beshu.ror.accesscontrol.blocks.rules.auth.{JwtAuthRule, JwtAuthenticationRule, JwtAuthorizationRule, JwtPseudoAuthorizationRule} +import tech.beshu.ror.accesscontrol.domain.GroupsLogic +import tech.beshu.ror.accesscontrol.factory.GlobalSettings + +object JwtAuthRulesDecoders + extends JwtLikeRulesDecoders[ + JwtAuthenticationRule, + JwtAuthorizationRule, + JwtPseudoAuthorizationRule, + JwtAuthRule, + JwtDef, + ] with Logging { + + override def humanReadableName: String = "JWT" + + override def createAuthenticationRule(definition: JwtDef, globalSettings: GlobalSettings): JwtAuthenticationRule = + new JwtAuthenticationRule(JwtAuthenticationRule.Settings(definition), globalSettings.userIdCaseSensitivity) + + override def createAuthorizationRule(definition: JwtDef, groupsLogic: GroupsLogic): JwtAuthorizationRule = + new JwtAuthorizationRule(JwtAuthorizationRule.Settings(definition, groupsLogic)) + + override def createAuthorizationRuleWithoutGroups(definition: JwtDef): JwtPseudoAuthorizationRule = + new JwtPseudoAuthorizationRule(JwtPseudoAuthorizationRule.Settings(definition)) + + override def createAuthRule(authnRule: JwtAuthenticationRule, authzRule: JwtAuthorizationRule): JwtAuthRule = + new JwtAuthRule(authnRule, authzRule) + + override def createAuthRuleWithoutGroups(authnRule: JwtAuthenticationRule, authzRule: JwtPseudoAuthorizationRule): JwtAuthRule = + new JwtAuthRule(authnRule, authzRule) + + override def serializeDefinitionId(definition: JwtDef): String = + definition.id.value.value + +} diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/JwtLikeRulesDecoders.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/JwtLikeRulesDecoders.scala new file mode 100644 index 0000000000..d269fffa19 --- /dev/null +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/JwtLikeRulesDecoders.scala @@ -0,0 +1,176 @@ +/* + * This file is part of ReadonlyREST. + * + * ReadonlyREST is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ReadonlyREST is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ReadonlyREST. If not, see http://www.gnu.org/licenses/ + */ +package tech.beshu.ror.accesscontrol.factory.decoders.rules.auth + +import eu.timepit.refined.types.string.NonEmptyString +import io.circe.Decoder +import org.apache.logging.log4j.scala.Logging +import tech.beshu.ror.accesscontrol.blocks.Block.RuleDefinition +import tech.beshu.ror.accesscontrol.blocks.ImpersonationWarning.ImpersonationWarningSupport +import tech.beshu.ror.accesscontrol.blocks.rules.Rule +import tech.beshu.ror.accesscontrol.blocks.rules.Rule.{AuthRule, AuthenticationRule, AuthorizationRule, RuleName} +import tech.beshu.ror.accesscontrol.blocks.users.LocalUsersContext.LocalUsersSupport +import tech.beshu.ror.accesscontrol.blocks.variables.runtime.VariableContext.VariableUsage +import tech.beshu.ror.accesscontrol.domain.GroupsLogic +import tech.beshu.ror.accesscontrol.factory.GlobalSettings +import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.Reason.Message +import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.RulesLevelCreationError +import tech.beshu.ror.accesscontrol.factory.decoders.common.nonEmptyStringDecoder +import tech.beshu.ror.accesscontrol.factory.decoders.definitions.Definitions +import tech.beshu.ror.accesscontrol.factory.decoders.rules.RuleBaseDecoder.RuleBaseDecoderWithoutAssociatedFields +import tech.beshu.ror.accesscontrol.factory.decoders.rules.auth.groups.GroupsLogicDecoder +import tech.beshu.ror.accesscontrol.factory.decoders.rules.auth.groups.GroupsLogicRepresentationDecoder.GroupsLogicDecodingResult +import tech.beshu.ror.accesscontrol.utils.CirceOps.* +import tech.beshu.ror.implicits.* + +// Common decoder for JWT rules and ROR KBN rules. They are very similar, and their decoding logic is mostly the same. +trait JwtLikeRulesDecoders[ + AUTHN_RULE <: AuthenticationRule : RuleName : VariableUsage : LocalUsersSupport : ImpersonationWarningSupport, + AUTHZ_RULE <: AuthorizationRule : RuleName : VariableUsage : LocalUsersSupport : ImpersonationWarningSupport, + AUTHZ_WITHOUT_GROUPS_RULE <: AuthorizationRule, + AUTH_RULE <: AuthRule : RuleName : VariableUsage : LocalUsersSupport : ImpersonationWarningSupport, + DEFINITION <: Definitions.Item, +] { + this: Logging => + + def humanReadableName: String + + def createAuthenticationRule(definition: DEFINITION, + globalSettings: GlobalSettings): AUTHN_RULE + + def createAuthorizationRule(definition: DEFINITION, + groupsLogic: GroupsLogic): AUTHZ_RULE + + def createAuthRule(authnRule: AUTHN_RULE, + authzRule: AUTHZ_RULE): AUTH_RULE + + def createAuthorizationRuleWithoutGroups(definition: DEFINITION): AUTHZ_WITHOUT_GROUPS_RULE + + def createAuthRuleWithoutGroups(authnRule: AUTHN_RULE, + authzRule: AUTHZ_WITHOUT_GROUPS_RULE): AUTH_RULE + + def serializeDefinitionId(definition: DEFINITION): String + + class AuthenticationRuleDecoder(definitions: Definitions[DEFINITION], + globalSettings: GlobalSettings) extends RuleBaseDecoderWithoutAssociatedFields[AUTHN_RULE] { + override protected def decoder: Decoder[RuleDefinition[AUTHN_RULE]] = + nameAndGroupsSimpleDecoder + .or(nameAndGroupsExtendedDecoder[AUTHN_RULE]) + .toSyncDecoder + .emapE { case (name, groupsLogicOpt) => + val definitionOpt = definitions.items.find(d => serializeDefinitionId(d) == name) + (definitionOpt, groupsLogicOpt) match { + case (Some(_), Some(_)) => + Left(RulesLevelCreationError(Message(s"Cannot create ${RuleName[AUTHN_RULE].name.show}, because there are superfluous groups settings. Remove the groups settings, or use ${RuleName[AUTHZ_RULE].name.show} or ${RuleName[AUTH_RULE].name.show} rule, if group settings are required."))) + case (Some(definition), None) => + val rule = createAuthenticationRule(definition, globalSettings) + Right(RuleDefinition.create(rule)) + case (None, _) => + Left(cannotFindDefinition(name)) + } + } + .decoder + } + + class AuthorizationRuleDecoder(definitions: Definitions[DEFINITION]) extends RuleBaseDecoderWithoutAssociatedFields[AUTHZ_RULE] { + override protected def decoder: Decoder[RuleDefinition[AUTHZ_RULE]] = + nameAndGroupsSimpleDecoder + .or(nameAndGroupsExtendedDecoder[AUTHZ_RULE]) + .toSyncDecoder + .emapE { case (name, groupsLogicOpt) => + val definitionOpt = definitions.items.find(d => serializeDefinitionId(d) == name) + (definitionOpt, groupsLogicOpt) match { + case (Some(definition), Some(groupsLogic)) => + val rule = createAuthorizationRule(definition, groupsLogic) + Right(RuleDefinition.create[AUTHZ_RULE](rule)) + case (Some(_), None) => + Left(RulesLevelCreationError(Message(s"Cannot create ${RuleName[AUTHZ_RULE].name.show} - missing groups logic (https://github.com/beshu-tech/readonlyrest-docs/blob/master/details/authorization-rules-details.md#checking-groups-logic)"))) + case (None, _) => + Left(cannotFindDefinition(name)) + } + } + .decoder + } + + class AuthRuleDecoder(definitions: Definitions[DEFINITION], + globalSettings: GlobalSettings) extends RuleBaseDecoderWithoutAssociatedFields[AUTH_RULE] { + override protected def decoder: Decoder[RuleDefinition[AUTH_RULE]] = + nameAndGroupsSimpleDecoder + .or(nameAndGroupsExtendedDecoder[AUTH_RULE]) + .toSyncDecoder + .emapE { case (name, groupsLogicOpt) => + val definitionOpt = definitions.items.find(d => serializeDefinitionId(d) == name) + (definitionOpt, groupsLogicOpt) match { + case (Some(definition), Some(groupsLogic)) => + val authentication = createAuthenticationRule(definition, globalSettings) + val authorization = createAuthorizationRule(definition, groupsLogic) + val rule = createAuthRule(authentication, authorization) + Right(RuleDefinition.create(rule)) + case (Some(definition), None) => + logger.warn( + s"""Missing groups logic settings in ${RuleName[AUTH_RULE].name.show} rule. + |For old configs, ROR treats this as `groups_any_of: ["*"]`. + |This syntax is deprecated. Add groups logic (https://github.com/beshu-tech/readonlyrest-docs/blob/master/details/authorization-rules-details.md#checking-groups-logic), + |or use ${RuleName[AUTHN_RULE].name.show} if you only need authentication. + |""".stripMargin + ) + val authentication = createAuthenticationRule(definition, globalSettings) + val authorization = createAuthorizationRuleWithoutGroups(definition) + val rule = createAuthRuleWithoutGroups(authentication, authorization) + Right(RuleDefinition.create(rule)) + case (None, _) => + Left(cannotFindDefinition(name)) + } + } + .decoder + } + + private def nameAndGroupsSimpleDecoder: Decoder[(String, Option[GroupsLogic])] = + DecoderHelpers + .decodeStringLikeNonEmpty + .map(_.value) + .map((_, None)) + + private def nameAndGroupsExtendedDecoder[RULE <: Rule : RuleName]: Decoder[(String, Option[GroupsLogic])] = + Decoder + .instance { c => + for { + definitionName <- c.downField("name").as[NonEmptyString].map(_.value) + groupsLogicDecodingResult <- GroupsLogicDecoder.decoder[RULE].apply(c) + } yield (definitionName, groupsLogicDecodingResult) + } + .toSyncDecoder + .emapE { + case (name, groupsLogicDecodingResult) => + groupsLogicDecodingResult match { + case GroupsLogicDecodingResult.Success(groupsLogic) => + Right((name, Some(groupsLogic))) + case GroupsLogicDecodingResult.GroupsLogicNotDefined(_) => + Right((name, None)) + case GroupsLogicDecodingResult.MultipleGroupsLogicsDefined(_, fields) => + val fieldsStr = fields.map(f => s"'$f'").mkString(" or ") + Left(RulesLevelCreationError(Message( + s"Please specify either $fieldsStr for $humanReadableName authorization rule '$name'" + ))) + } + } + .decoder + + private def cannotFindDefinition(name: String) = + RulesLevelCreationError(Message(s"Cannot find $humanReadableName definition with name: $name")) + +} diff --git a/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/RorKbnRulesDecoders.scala b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/RorKbnRulesDecoders.scala index c79c91a551..2f4e3b7860 100644 --- a/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/RorKbnRulesDecoders.scala +++ b/core/src/main/scala/tech/beshu/ror/accesscontrol/factory/decoders/rules/auth/RorKbnRulesDecoders.scala @@ -16,150 +16,42 @@ */ package tech.beshu.ror.accesscontrol.factory.decoders.rules.auth -import io.circe.Decoder import org.apache.logging.log4j.scala.Logging -import tech.beshu.ror.accesscontrol.blocks.Block.RuleDefinition import tech.beshu.ror.accesscontrol.blocks.definitions.RorKbnDef -import tech.beshu.ror.accesscontrol.blocks.rules.Rule -import tech.beshu.ror.accesscontrol.blocks.rules.Rule.RuleName import tech.beshu.ror.accesscontrol.blocks.rules.auth.{RorKbnAuthRule, RorKbnAuthenticationRule, RorKbnAuthorizationRule} import tech.beshu.ror.accesscontrol.domain.GroupIdLike.GroupIdPattern import tech.beshu.ror.accesscontrol.domain.{GroupIds, GroupsLogic} import tech.beshu.ror.accesscontrol.factory.GlobalSettings -import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.Reason.Message -import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.RulesLevelCreationError -import tech.beshu.ror.accesscontrol.factory.decoders.definitions.Definitions -import tech.beshu.ror.accesscontrol.factory.decoders.definitions.RorKbnDefinitionsDecoder.* -import tech.beshu.ror.accesscontrol.factory.decoders.rules.RuleBaseDecoder.RuleBaseDecoderWithoutAssociatedFields -import tech.beshu.ror.accesscontrol.factory.decoders.rules.auth.RorKbnRulesDecodersHelper.* -import tech.beshu.ror.accesscontrol.factory.decoders.rules.auth.groups.GroupsLogicDecoder -import tech.beshu.ror.accesscontrol.factory.decoders.rules.auth.groups.GroupsLogicRepresentationDecoder.GroupsLogicDecodingResult -import tech.beshu.ror.accesscontrol.utils.CirceOps.* -import tech.beshu.ror.implicits.* import tech.beshu.ror.utils.RefinedUtils.nes import tech.beshu.ror.utils.uniquelist.UniqueNonEmptyList -class RorKbnAuthenticationRuleDecoder(rorKbnDefinitions: Definitions[RorKbnDef], - globalSettings: GlobalSettings) - extends RuleBaseDecoderWithoutAssociatedFields[RorKbnAuthenticationRule] with Logging { +object RorKbnRulesDecoders + extends JwtLikeRulesDecoders[ + RorKbnAuthenticationRule, + RorKbnAuthorizationRule, + RorKbnAuthorizationRule, + RorKbnAuthRule, + RorKbnDef, + ] with Logging { - override protected def decoder: Decoder[RuleDefinition[RorKbnAuthenticationRule]] = { - nameAndGroupsSimpleDecoder - .or(nameAndGroupsExtendedDecoder[RorKbnAuthenticationRule]) - .toSyncDecoder - .emapE { case (name, groupsLogicOpt) => - val foundKbnDef = rorKbnDefinitions.items.find(_.id === name) - (foundKbnDef, groupsLogicOpt) match { - case (Some(_), Some(_)) => - Left(RulesLevelCreationError(Message(s"Cannot create ${RorKbnAuthenticationRule.Name.name.show}, because there are superfluous groups settings. Remove the groups settings, or use ${RorKbnAuthorizationRule.Name.name.show} or ${RorKbnAuthRule.Name.name.show} rule, if group settings are required."))) - case (Some(rorKbnDef), None) => - val settings = RorKbnAuthenticationRule.Settings(rorKbnDef) - val rule = new RorKbnAuthenticationRule(settings, globalSettings.userIdCaseSensitivity) - Right(RuleDefinition.create(rule)) - case (None, _) => - Left(cannotFindRorKibanaDefinition(name)) - } - } - .decoder - } -} - -class RorKbnAuthorizationRuleDecoder(rorKbnDefinitions: Definitions[RorKbnDef]) - extends RuleBaseDecoderWithoutAssociatedFields[RorKbnAuthorizationRule] with Logging { + override def humanReadableName: String = "ROR Kibana" - override protected def decoder: Decoder[RuleDefinition[RorKbnAuthorizationRule]] = { - nameAndGroupsSimpleDecoder - .or(nameAndGroupsExtendedDecoder[RorKbnAuthorizationRule]) - .toSyncDecoder - .emapE { case (name, groupsLogicOpt) => - val foundKbnDef = rorKbnDefinitions.items.find(_.id === name) - (foundKbnDef, groupsLogicOpt) match { - case (Some(rorKbnDef), Some(groupsLogic)) => - val settings = RorKbnAuthorizationRule.Settings(rorKbnDef, groupsLogic) - val rule = new RorKbnAuthorizationRule(settings) - Right(RuleDefinition.create[RorKbnAuthorizationRule](rule)) - case (Some(_), None) => - Left(RulesLevelCreationError(Message(s"Cannot create ${RorKbnAuthorizationRule.Name.name.show} - missing groups logic (https://github.com/beshu-tech/readonlyrest-docs/blob/master/details/authorization-rules-details.md#checking-groups-logic)"))) - case (None, _) => - Left(cannotFindRorKibanaDefinition(name)) - } - } - .decoder - } -} + override def createAuthenticationRule(definition: RorKbnDef, globalSettings: GlobalSettings): RorKbnAuthenticationRule = + new RorKbnAuthenticationRule(RorKbnAuthenticationRule.Settings(definition), globalSettings.userIdCaseSensitivity) -class RorKbnAuthRuleDecoder(rorKbnDefinitions: Definitions[RorKbnDef], - globalSettings: GlobalSettings) - extends RuleBaseDecoderWithoutAssociatedFields[RorKbnAuthRule] with Logging { - - override protected def decoder: Decoder[RuleDefinition[RorKbnAuthRule]] = { - nameAndGroupsSimpleDecoder - .or(nameAndGroupsExtendedDecoder[RorKbnAuthRule]) - .toSyncDecoder - .emapE { case (name, groupsLogicOpt) => - val foundKbnDef = rorKbnDefinitions.items.find(_.id === name) - (foundKbnDef, groupsLogicOpt) match { - case (Some(rorKbnDef), groupsLogicOpt) => - val groupsLogic = groupsLogicOpt match { - case Some(groupsLogic) => - groupsLogic - case None => - logger.warn( - s"""Missing groups logic settings in ${RorKbnAuthRule.Name.name.show} rule. - |For old configs, ROR treats this as `groups_any_of: ["*"]`. - |This syntax is deprecated. Add groups logic (https://github.com/beshu-tech/readonlyrest-docs/blob/master/details/authorization-rules-details.md#checking-groups-logic), - |or use ${RorKbnAuthenticationRule.Name.name.show} if you only need authentication. - |""".stripMargin - ) - GroupsLogic.AnyOf(GroupIds(UniqueNonEmptyList.of(GroupIdPattern.fromNes(nes("*"))))) - } - val rule = new RorKbnAuthRule( - authentication = new RorKbnAuthenticationRule(RorKbnAuthenticationRule.Settings(rorKbnDef), globalSettings.userIdCaseSensitivity), - authorization = new RorKbnAuthorizationRule(RorKbnAuthorizationRule.Settings(rorKbnDef, groupsLogic)), - ) - Right(RuleDefinition.create(rule)) - case (None, _) => - Left(cannotFindRorKibanaDefinition(name)) - } - } - .decoder - } -} + override def createAuthorizationRule(definition: RorKbnDef, groupsLogic: GroupsLogic): RorKbnAuthorizationRule = + new RorKbnAuthorizationRule(RorKbnAuthorizationRule.Settings(definition, groupsLogic)) -private object RorKbnRulesDecodersHelper { + override def createAuthorizationRuleWithoutGroups(definition: RorKbnDef): RorKbnAuthorizationRule = + createAuthorizationRule(definition, GroupsLogic.AnyOf(GroupIds(UniqueNonEmptyList.of(GroupIdPattern.fromNes(nes("*")))))) - def cannotFindRorKibanaDefinition(name: RorKbnDef.Name) = - RulesLevelCreationError(Message(s"Cannot find ROR Kibana definition with name: ${name.show}")) + override def createAuthRule(authnRule: RorKbnAuthenticationRule, authzRule: RorKbnAuthorizationRule): RorKbnAuthRule = + new RorKbnAuthRule(authnRule, authzRule) - val nameAndGroupsSimpleDecoder: Decoder[(RorKbnDef.Name, Option[GroupsLogic])] = - DecoderHelpers - .decodeStringLikeNonEmpty - .map(RorKbnDef.Name.apply) - .map((_, None)) + override def createAuthRuleWithoutGroups(authnRule: RorKbnAuthenticationRule, authzRule: RorKbnAuthorizationRule): RorKbnAuthRule = + new RorKbnAuthRule(authnRule, authzRule) - def nameAndGroupsExtendedDecoder[T <: Rule](implicit ruleName: RuleName[T]): Decoder[(RorKbnDef.Name, Option[GroupsLogic])] = - Decoder - .instance { c => - for { - rorKbnDefName <- c.downField("name").as[RorKbnDef.Name] - groupsLogicDecodingResult <- GroupsLogicDecoder.decoder[T].apply(c) - } yield (rorKbnDefName, groupsLogicDecodingResult) - } - .toSyncDecoder - .emapE { - case (name, groupsLogicDecodingResult) => - groupsLogicDecodingResult match { - case GroupsLogicDecodingResult.Success(groupsLogic) => - Right((name, Some(groupsLogic))) - case GroupsLogicDecodingResult.GroupsLogicNotDefined(_) => - Right((name, None)) - case GroupsLogicDecodingResult.MultipleGroupsLogicsDefined(_, fields) => - val fieldsStr = fields.map(f => s"'$f'").mkString(" or ") - Left(RulesLevelCreationError(Message( - s"Please specify either $fieldsStr for ROR Kibana rule '${name.show}'" - ))) - } - } - .decoder + override def serializeDefinitionId(definition: RorKbnDef): String = + definition.id.value.value } diff --git a/core/src/test/scala/tech/beshu/ror/unit/acl/blocks/rules/auth/JwtAuthRuleTests.scala b/core/src/test/scala/tech/beshu/ror/unit/acl/blocks/rules/auth/JwtAuthRuleTests.scala index 90a2fb9bb0..22dcd22a26 100644 --- a/core/src/test/scala/tech/beshu/ror/unit/acl/blocks/rules/auth/JwtAuthRuleTests.scala +++ b/core/src/test/scala/tech/beshu/ror/unit/acl/blocks/rules/auth/JwtAuthRuleTests.scala @@ -33,8 +33,7 @@ import tech.beshu.ror.accesscontrol.blocks.definitions.ExternalAuthenticationSer import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef.{GroupsConfig, SignatureCheckMethod} import tech.beshu.ror.accesscontrol.blocks.metadata.UserMetadata import tech.beshu.ror.accesscontrol.blocks.rules.Rule.RuleResult.{Fulfilled, Rejected} -import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthRule -import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthRule.Groups +import tech.beshu.ror.accesscontrol.blocks.rules.auth.{JwtAuthRule, JwtAuthenticationRule, JwtAuthorizationRule, JwtPseudoAuthorizationRule} import tech.beshu.ror.accesscontrol.domain import tech.beshu.ror.accesscontrol.domain.GroupIdLike.GroupId import tech.beshu.ror.accesscontrol.domain.LoggedUser.DirectlyLoggedUser @@ -252,7 +251,7 @@ class JwtAuthRuleTests userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) ), - configuredGroups = Groups.NotDefined, + configuredGroups = None, tokenHeader = bearerHeader(jwt) ) { blockContext => @@ -392,7 +391,7 @@ class JwtAuthRuleTests namesClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.name)].name"))) )) ), - configuredGroups = Groups.Defined(GroupsLogic.AnyOf(GroupIds( + configuredGroups = Some(GroupsLogic.AnyOf(GroupIds( UniqueNonEmptyList.of(GroupId("group3"), GroupId("group2")) ))), tokenHeader = bearerHeader(jwt) @@ -426,7 +425,7 @@ class JwtAuthRuleTests namesClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.name)].name"))) )) ), - configuredGroups = Groups.Defined(GroupsLogic.AnyOf(GroupIds( + configuredGroups = Some(GroupsLogic.AnyOf(GroupIds( UniqueNonEmptyList.of(GroupId("group3"), GroupIdLike.from("*2")) ))), tokenHeader = bearerHeader(jwt) @@ -460,7 +459,7 @@ class JwtAuthRuleTests namesClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.name)].name"))) )) ), - configuredGroups = Groups.Defined(GroupsLogic.AllOf(GroupIds( + configuredGroups = Some(GroupsLogic.AllOf(GroupIds( UniqueNonEmptyList.of(GroupId("group1"), GroupId("group2")) ))), tokenHeader = bearerHeader(jwt) @@ -494,7 +493,7 @@ class JwtAuthRuleTests namesClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.name)].name"))) )) ), - configuredGroups = Groups.Defined(GroupsLogic.AllOf(GroupIds( + configuredGroups = Some(GroupsLogic.AllOf(GroupIds( UniqueNonEmptyList.of(GroupIdLike.from("*1"), GroupIdLike.from("*2")) ))), tokenHeader = bearerHeader(jwt) @@ -549,6 +548,24 @@ class JwtAuthRuleTests )(blockContext) } } + "preferred group is not on the groups list from JWT" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + "groups" := List("group1", "group2") + )) + assertNotMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) + ), + tokenHeader = bearerHeader(jwt), + preferredGroupId = Some(GroupId("group3")) + ) + } } "not match" when { "token has invalid HS256 signature" in { @@ -636,7 +653,7 @@ class JwtAuthRuleTests userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("tech.beshu.groups.subgroups")), None)) ), - configuredGroups = Groups.Defined(GroupsLogic.AnyOf(GroupIds( + configuredGroups = Some(GroupsLogic.AnyOf(GroupIds( UniqueNonEmptyList.of(GroupId("group1")) ))), tokenHeader = bearerHeader(jwt) @@ -656,7 +673,7 @@ class JwtAuthRuleTests userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) ), - configuredGroups = Groups.Defined(GroupsLogic.AnyOf(GroupIds( + configuredGroups = Some(GroupsLogic.AnyOf(GroupIds( UniqueNonEmptyList.of(GroupId("group3"), GroupId("group4")) ))), tokenHeader = bearerHeader(jwt) @@ -676,30 +693,12 @@ class JwtAuthRuleTests userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) ), - configuredGroups = Groups.Defined(GroupsLogic.AllOf(GroupIds( + configuredGroups = Some(GroupsLogic.AllOf(GroupIds( UniqueNonEmptyList.of(GroupId("group2"), GroupId("group3")) ))), tokenHeader = bearerHeader(jwt) ) } - "preferred group is not on the groups list from JWT" in { - val key: Key = Jwts.SIG.HS256.key().build() - val jwt = Jwt(key, claims = List( - "userId" := "user1", - "groups" := List("group1", "group2") - )) - assertNotMatchRule( - configuredJwtDef = JwtDef( - JwtDef.Name("test"), - AuthorizationTokenDef(Header.Name.authorization, "Bearer "), - SignatureCheckMethod.Hmac(key.getEncoded), - userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), - groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) - ), - tokenHeader = bearerHeader(jwt), - preferredGroupId = Some(GroupId("group3")) - ) - } "preferred group is not on the permitted groups list" in { val key: Key = Jwts.SIG.HS256.key().build() val jwt = Jwt(key, claims = List( @@ -714,7 +713,7 @@ class JwtAuthRuleTests userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) ), - configuredGroups = Groups.Defined(GroupsLogic.AnyOf(GroupIds( + configuredGroups = Some(GroupsLogic.AnyOf(GroupIds( UniqueNonEmptyList.of(GroupId("group2")) ))), tokenHeader = bearerHeader(jwt), @@ -725,24 +724,32 @@ class JwtAuthRuleTests } private def assertMatchRule(configuredJwtDef: JwtDef, - configuredGroups: Groups = Groups.NotDefined, + configuredGroups: Option[GroupsLogic] = None, tokenHeader: Header, preferredGroupId: Option[GroupId] = None) (blockContextAssertion: BlockContext => Unit): Unit = assertRule(configuredJwtDef, configuredGroups, tokenHeader, preferredGroupId, Some(blockContextAssertion)) private def assertNotMatchRule(configuredJwtDef: JwtDef, - configuredGroups: Groups = Groups.NotDefined, + configuredGroups: Option[GroupsLogic] = None, tokenHeader: Header, preferredGroupId: Option[GroupId] = None): Unit = assertRule(configuredJwtDef, configuredGroups, tokenHeader, preferredGroupId, blockContextAssertion = None) private def assertRule(configuredJwtDef: JwtDef, - configuredGroups: Groups, + configuredGroups: Option[GroupsLogic], tokenHeader: Header, preferredGroup: Option[GroupId], blockContextAssertion: Option[BlockContext => Unit]) = { - val rule = new JwtAuthRule(JwtAuthRule.Settings(configuredJwtDef, configuredGroups), CaseSensitivity.Enabled) + val authorization: JwtAuthorizationRule | JwtPseudoAuthorizationRule = configuredGroups match { + case Some(groupsLogic) => new JwtAuthorizationRule(JwtAuthorizationRule.Settings(configuredJwtDef, groupsLogic)) + case None => new JwtPseudoAuthorizationRule(JwtPseudoAuthorizationRule.Settings(configuredJwtDef)) + } + val rule = new JwtAuthRule( + new JwtAuthenticationRule(JwtAuthenticationRule.Settings(configuredJwtDef), CaseSensitivity.Enabled), + authorization, + ) + val requestContext = MockRequestContext.indices.withHeaders( preferredGroup.map(_.toCurrentGroupHeader).toSeq :+ tokenHeader ) diff --git a/core/src/test/scala/tech/beshu/ror/unit/acl/blocks/rules/auth/JwtAuthenticationRuleTests.scala b/core/src/test/scala/tech/beshu/ror/unit/acl/blocks/rules/auth/JwtAuthenticationRuleTests.scala new file mode 100644 index 0000000000..2be82d9372 --- /dev/null +++ b/core/src/test/scala/tech/beshu/ror/unit/acl/blocks/rules/auth/JwtAuthenticationRuleTests.scala @@ -0,0 +1,574 @@ +/* + * This file is part of ReadonlyREST. + * + * ReadonlyREST is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ReadonlyREST is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ReadonlyREST. If not, see http://www.gnu.org/licenses/ + */ +package tech.beshu.ror.unit.acl.blocks.rules.auth + +import cats.data.NonEmptyList +import eu.timepit.refined.api.Refined +import eu.timepit.refined.types.string.NonEmptyString +import io.jsonwebtoken.Jwts +import monix.eval.Task +import monix.execution.Scheduler.Implicits.global +import org.scalamock.scalatest.MockFactory +import org.scalatest.Inside +import org.scalatest.matchers.should.Matchers.* +import org.scalatest.wordspec.AnyWordSpec +import tech.beshu.ror.accesscontrol.blocks.BlockContext +import tech.beshu.ror.accesscontrol.blocks.BlockContext.GeneralIndexRequestBlockContext +import tech.beshu.ror.accesscontrol.blocks.definitions.* +import tech.beshu.ror.accesscontrol.blocks.definitions.ExternalAuthenticationService.Name +import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef.{GroupsConfig, SignatureCheckMethod} +import tech.beshu.ror.accesscontrol.blocks.metadata.UserMetadata +import tech.beshu.ror.accesscontrol.blocks.rules.Rule.RuleResult.{Fulfilled, Rejected} +import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthenticationRule +import tech.beshu.ror.accesscontrol.domain +import tech.beshu.ror.accesscontrol.domain.GroupIdLike.GroupId +import tech.beshu.ror.accesscontrol.domain.LoggedUser.DirectlyLoggedUser +import tech.beshu.ror.accesscontrol.domain.{Jwt as _, *} +import tech.beshu.ror.mocks.MockRequestContext +import tech.beshu.ror.syntax.* +import tech.beshu.ror.utils.DurationOps.* +import tech.beshu.ror.utils.TestsUtils.* +import tech.beshu.ror.utils.WithDummyRequestIdSupport +import tech.beshu.ror.utils.misc.JwtUtils.* +import tech.beshu.ror.utils.misc.Random + +import java.security.Key +import scala.concurrent.duration.* +import scala.jdk.CollectionConverters.* +import scala.language.postfixOps + +class JwtAuthenticationRuleTests + extends AnyWordSpec with MockFactory with Inside with BlockContextAssertion with WithDummyRequestIdSupport { + + "A JwtAuthenticationRule" should { + "match" when { + "token has valid HS256 signature" in { + val secret: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(secret, claims = List.empty) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(secret.getEncoded), + userClaim = None, + groupsConfig = None + ), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())) + )(blockContext) + } + } + "token has valid RS256 signature" in { + val (pub, secret) = Random.generateRsaRandomKeys + val jwt = Jwt(secret, claims = List.empty) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Rsa(pub), + userClaim = None, + groupsConfig = None + ), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())) + )(blockContext) + } + } + "token has no signature and external auth service returns true" in { + val jwt = Jwt(claims = List.empty) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.NoCheck(authService(jwt.stringify(), authenticated = true)), + userClaim = None, + groupsConfig = None + ), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())) + )(blockContext) + } + } + "token has no signature and external auth service state is cached" in { + val validJwt = Jwt(claims = List.empty) + val invalidJwt = Jwt(claims = List("user" := "testuser")) + val authService = cachedAuthService(validJwt.stringify(), invalidJwt.stringify()) + val jwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.NoCheck(authService), + userClaim = None, + groupsConfig = None + ) + + def checkValidToken(): Unit = assertMatchRule( + configuredJwtDef = jwtDef, + tokenHeader = bearerHeader(validJwt) + ) { + blockContext => + assertBlockContext( + jwt = Some(domain.Jwt.Payload(validJwt.defaultClaims())) + )(blockContext) + } + + def checkInvalidToken(): Unit = assertNotMatchRule( + configuredJwtDef = jwtDef, + tokenHeader = bearerHeader(invalidJwt) + ) + + checkValidToken() + checkValidToken() + checkInvalidToken() + checkValidToken() + } + "user claim name is defined and userId is passed in JWT token claim" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1" + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = None, + ), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + loggedUser = Some(DirectlyLoggedUser(User.Id("user1"))), + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())) + )(blockContext) + } + } + "group IDs claim name is defined and groups are passed in JWT token claim (no preferred group)" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + "groups" := List("group1", "group2") + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) + ), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + loggedUser = Some(DirectlyLoggedUser(User.Id("user1"))), + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())) + )(blockContext) + } + } + "group IDs claim name is defined and groups are passed in JWT token claim (with preferred group)" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + "groups" := List("group1", "group2") + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) + ), + tokenHeader = bearerHeader(jwt), + preferredGroupId = Some(GroupId("group1")) + ) { + blockContext => + assertBlockContext( + loggedUser = Some(DirectlyLoggedUser(User.Id("user1"))), + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())), + currentGroup = Some(GroupId("group1")) + )(blockContext) + } + } + "group IDs claim name is defined as http address and groups are passed in JWT token claim" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + "https://{domain}/claims/roles" := List("group1", "group2") + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("https://{domain}/claims/roles")), None)) + ), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + loggedUser = Some(DirectlyLoggedUser(User.Id("user1"))), + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())) + )(blockContext) + } + } + "group IDs claim name is defined and no groups field is passed in JWT token claim" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1" + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) + ), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + loggedUser = Some(DirectlyLoggedUser(User.Id("user1"))), + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())), + )(blockContext) + } + } + "group IDs claim path is defined and groups are passed in JWT token claim" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + "tech" :-> "beshu" :-> "groups" := List("group1", "group2") + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("tech.beshu.groups")), None)) + ), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + loggedUser = Some(DirectlyLoggedUser(User.Id("user1"))), + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())) + )(blockContext) + } + } + "group names claim is defined and group names are passed in JWT token claim" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + Claim(NonEmptyList.one(ClaimKey("groups")), List( + Map("id" -> "group1", "name" -> "Group 1").asJava, + Map("id" -> "group2", "name" -> "Group 2").asJava + ).asJava) + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig( + idsClaim = domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.id)].id")), + namesClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.name)].name"))) + )) + ), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + loggedUser = Some(DirectlyLoggedUser(User.Id("user1"))), + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())), + )(blockContext) + } + } + "group names claim is defined and group names passed in JWT token claim are malformed" when { + "group names count differs from the group ID count" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + Claim(NonEmptyList.one(ClaimKey("groups")), List( + Map("id" -> "group1", "name" -> List("Group 1", "Group A").asJava).asJava, + Map("id" -> "group2", "name" -> List("Group 2", "Group B").asJava).asJava + ).asJava) + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig( + idsClaim = domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.id)].id")), + namesClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.name)].name"))) + )) + ), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + loggedUser = Some(DirectlyLoggedUser(User.Id("user1"))), + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())), + )(blockContext) + } + } + "one group does not have a name" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + Claim(NonEmptyList.one(ClaimKey("groups")), List( + Map("id" -> "group1", "name" -> "Group 1").asJava, + Map("id" -> "group2").asJava, + Map("id" -> "group3", "name" -> "Group 3").asJava + ).asJava) + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.id)].id")), Some(domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.name)].name"))))) + ), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + loggedUser = Some(DirectlyLoggedUser(User.Id("user1"))), + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())), + )(blockContext) + } + } + } + "custom authorization header is used" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List.empty) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name("x-jwt-custom-header"), "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = None, + groupsConfig = None + ), + tokenHeader = bearerHeader("x-jwt-custom-header", jwt) + ) { + blockContext => + assertBlockContext( + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())) + )(blockContext) + } + } + "custom authorization token prefix is used" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List.empty) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name("x-jwt-custom-header"), "MyPrefix "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = None, + groupsConfig = None + ), + tokenHeader = new Header( + Header.Name("x-jwt-custom-header"), + NonEmptyString.unsafeFrom(s"MyPrefix ${jwt.stringify()}") + ) + ) { + blockContext => + assertBlockContext( + jwt = Some(domain.Jwt.Payload(jwt.defaultClaims())) + )(blockContext) + } + } + } + "not match" when { + "token has invalid HS256 signature" in { + val key1: Key = Jwts.SIG.HS256.key().build() + val key2: Key = Jwts.SIG.HS256.key().build() + val jwt2 = Jwt(key2, claims = List.empty) + assertNotMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key1.getEncoded), + userClaim = None, + groupsConfig = None + ), + tokenHeader = bearerHeader(jwt2) + ) + } + "token has invalid RS256 signature" in { + val (pub, _) = Random.generateRsaRandomKeys + val (_, secret) = Random.generateRsaRandomKeys + val jwt = Jwt(secret, claims = List.empty) + assertNotMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Rsa(pub), + userClaim = None, + groupsConfig = None + ), + tokenHeader = bearerHeader(jwt) + ) + } + "token has no signature but external auth service returns false" in { + val jwt = Jwt(claims = List.empty) + assertNotMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.NoCheck(authService(jwt.stringify(), authenticated = false)), + userClaim = None, + groupsConfig = None + ), + tokenHeader = bearerHeader(jwt) + ) + } + "user claim name is defined but userId isn't passed in JWT token claim" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List.empty) + assertNotMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = None + ), + tokenHeader = bearerHeader(jwt) + ) + } + "group IDs claim name is defined but groups aren't passed in JWT token claim" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List.empty) + assertNotMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) + ), + tokenHeader = bearerHeader(jwt) + ) + } + "preferred group is not on the groups list from JWT" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + "groups" := List("group1", "group2") + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) + ), + tokenHeader = bearerHeader(jwt), + preferredGroupId = Some(GroupId("group3")) + ) + } + } + } + + private def assertMatchRule(configuredJwtDef: JwtDef, + tokenHeader: Header, + preferredGroupId: Option[GroupId] = None) + (blockContextAssertion: BlockContext => Unit): Unit = + assertRule(configuredJwtDef, tokenHeader, preferredGroupId, Some(blockContextAssertion)) + + private def assertNotMatchRule(configuredJwtDef: JwtDef, + tokenHeader: Header, + preferredGroupId: Option[GroupId] = None): Unit = + assertRule(configuredJwtDef, tokenHeader, preferredGroupId, blockContextAssertion = None) + + private def assertRule(configuredJwtDef: JwtDef, + tokenHeader: Header, + preferredGroup: Option[GroupId], + blockContextAssertion: Option[BlockContext => Unit]) = { + val rule = new JwtAuthenticationRule(JwtAuthenticationRule.Settings(configuredJwtDef), CaseSensitivity.Enabled) + + val requestContext = MockRequestContext.indices.withHeaders( + preferredGroup.map(_.toCurrentGroupHeader).toSeq :+ tokenHeader + ) + val blockContext = GeneralIndexRequestBlockContext( + requestContext = requestContext, + userMetadata = UserMetadata.from(requestContext), + responseHeaders = Set.empty, + responseTransformations = List.empty, + filteredIndices = Set.empty, + allAllowedIndices = Set.empty + ) + val result = rule.check(blockContext).runSyncUnsafe(1 second) + blockContextAssertion match { + case Some(assertOutputBlockContext) => + inside(result) { case Fulfilled(outBlockContext) => + assertOutputBlockContext(outBlockContext) + } + case None => + result should be(Rejected()) + } + } + + private def authService(rawToken: String, authenticated: Boolean) = { + val service = mock[ExternalAuthenticationService] + (service.authenticate(_: Credentials)(_: RequestId)) + .expects(where { (credentials: Credentials, _) => credentials.secret === PlainTextSecret(NonEmptyString.unsafeFrom(rawToken)) }) + .returning(Task.now(authenticated)) + service + } + + private def cachedAuthService(authenticatedToken: String, unauthenticatedToken: String) = { + val service = mock[ExternalAuthenticationService] + (service.authenticate(_: Credentials)(_: RequestId)) + .expects(where { (credentials: Credentials, _) => credentials.secret === PlainTextSecret(NonEmptyString.unsafeFrom(authenticatedToken)) }) + .returning(Task.now(true)) + .once() + (service.authenticate(_: Credentials)(_: RequestId)) + .expects(where { (credentials: Credentials, _) => credentials.secret === PlainTextSecret(NonEmptyString.unsafeFrom(unauthenticatedToken)) }) + .returning(Task.now(false)) + .once() + (() => service.id) + .expects() + .returning(Name("external_service")) + (() => service.serviceTimeout) + .expects() + .anyNumberOfTimes() + .returning(Refined.unsafeApply(10 seconds)) + val ttl = (1 hour).toRefinedPositiveUnsafe + new CacheableExternalAuthenticationServiceDecorator(service, ttl) + } +} diff --git a/core/src/test/scala/tech/beshu/ror/unit/acl/blocks/rules/auth/JwtAuthorizationRuleTests.scala b/core/src/test/scala/tech/beshu/ror/unit/acl/blocks/rules/auth/JwtAuthorizationRuleTests.scala new file mode 100644 index 0000000000..859251c1b3 --- /dev/null +++ b/core/src/test/scala/tech/beshu/ror/unit/acl/blocks/rules/auth/JwtAuthorizationRuleTests.scala @@ -0,0 +1,308 @@ +/* + * This file is part of ReadonlyREST. + * + * ReadonlyREST is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ReadonlyREST is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ReadonlyREST. If not, see http://www.gnu.org/licenses/ + */ +package tech.beshu.ror.unit.acl.blocks.rules.auth + +import cats.data.NonEmptyList +import io.jsonwebtoken.Jwts +import monix.execution.Scheduler.Implicits.global +import org.scalamock.scalatest.MockFactory +import org.scalatest.Inside +import org.scalatest.matchers.should.Matchers.* +import org.scalatest.wordspec.AnyWordSpec +import tech.beshu.ror.accesscontrol.blocks.BlockContext +import tech.beshu.ror.accesscontrol.blocks.BlockContext.GeneralIndexRequestBlockContext +import tech.beshu.ror.accesscontrol.blocks.definitions.* +import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef.{GroupsConfig, SignatureCheckMethod} +import tech.beshu.ror.accesscontrol.blocks.metadata.UserMetadata +import tech.beshu.ror.accesscontrol.blocks.rules.Rule.RuleResult.{Fulfilled, Rejected} +import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthorizationRule +import tech.beshu.ror.accesscontrol.domain +import tech.beshu.ror.accesscontrol.domain.GroupIdLike.GroupId +import tech.beshu.ror.accesscontrol.domain.{Jwt as _, *} +import tech.beshu.ror.mocks.MockRequestContext +import tech.beshu.ror.syntax.* +import tech.beshu.ror.utils.TestsUtils.* +import tech.beshu.ror.utils.WithDummyRequestIdSupport +import tech.beshu.ror.utils.misc.JwtUtils.* +import tech.beshu.ror.utils.uniquelist.{UniqueList, UniqueNonEmptyList} + +import java.security.Key +import scala.concurrent.duration.* +import scala.jdk.CollectionConverters.* +import scala.language.postfixOps + +class JwtAuthorizationRuleTests + extends AnyWordSpec with MockFactory with Inside with BlockContextAssertion with WithDummyRequestIdSupport { + + "A JwtAuthorizationRule" should { + "match" when { + "rule groups with 'or' logic are defined and intersection between those groups and JWT ones is not empty (1)" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + Claim(NonEmptyList.one(ClaimKey("groups")), List( + Map("id" -> "group1", "name" -> "Group 1").asJava, + Map("id" -> "group2", "name" -> "Group 2").asJava + ).asJava) + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig( + idsClaim = domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.id)].id")), + namesClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.name)].name"))) + )) + ), + configuredGroups = GroupsLogic.AnyOf(GroupIds( + UniqueNonEmptyList.of(GroupId("group3"), GroupId("group2")) + )), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + currentGroup = Some(GroupId("group2")), + availableGroups = UniqueList.of(group("group2", "Group 2")) + )(blockContext) + } + } + "rule groups with 'or' logic are defined and intersection between those groups and JWT ones is not empty (2)" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + Claim(NonEmptyList.one(ClaimKey("groups")), List( + Map("id" -> "group1", "name" -> "Group 1").asJava, + Map("id" -> "group2", "name" -> "Group 2").asJava + ).asJava) + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig( + idsClaim = domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.id)].id")), + namesClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.name)].name"))) + )) + ), + configuredGroups = GroupsLogic.AnyOf(GroupIds( + UniqueNonEmptyList.of(GroupId("group3"), GroupIdLike.from("*2")) + )), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + currentGroup = Some(GroupId("group2")), + availableGroups = UniqueList.of(group("group2", "Group 2")) + )(blockContext) + } + } + "rule groups with 'and' logic are defined and intersection between those groups and JWT ones is not empty (1)" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + Claim(NonEmptyList.one(ClaimKey("groups")), List( + Map("id" -> "group1", "name" -> "Group 1").asJava, + Map("id" -> "group2", "name" -> "Group 2").asJava + ).asJava) + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig( + idsClaim = domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.id)].id")), + namesClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.name)].name"))) + )) + ), + configuredGroups = GroupsLogic.AllOf(GroupIds( + UniqueNonEmptyList.of(GroupId("group1"), GroupId("group2")) + )), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + currentGroup = Some(GroupId("group1")), + availableGroups = UniqueList.of(group("group1", "Group 1"), group("group2", "Group 2")) + )(blockContext) + } + } + "rule groups with 'and' logic are defined and intersection between those groups and JWT ones is not empty (2)" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + Claim(NonEmptyList.one(ClaimKey("groups")), List( + Map("id" -> "group1", "name" -> "Group 1").asJava, + Map("id" -> "group2", "name" -> "Group 2").asJava + ).asJava) + )) + assertMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig( + idsClaim = domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.id)].id")), + namesClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("groups[?(@.name)].name"))) + )) + ), + configuredGroups = GroupsLogic.AllOf(GroupIds( + UniqueNonEmptyList.of(GroupIdLike.from("*1"), GroupIdLike.from("*2")) + )), + tokenHeader = bearerHeader(jwt) + ) { + blockContext => + assertBlockContext( + currentGroup = Some(GroupId("group1")), + availableGroups = UniqueList.of(group("group1", "Group 1"), group("group2", "Group 2")) + )(blockContext) + } + } + } + "not match" when { + "group IDs claim path is wrong" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + "groups" := List("group1", "group2") + )) + assertNotMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("tech.beshu.groups.subgroups")), None)) + ), + configuredGroups = GroupsLogic.AnyOf(GroupIds( + UniqueNonEmptyList.of(GroupId("group1")) + )), + tokenHeader = bearerHeader(jwt) + ) + } + "rule groups with 'or' logic are defined and intersection between those groups and JWT ones is empty" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + "groups" := List("group1", "group2") + )) + assertNotMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) + ), + configuredGroups = GroupsLogic.AnyOf(GroupIds( + UniqueNonEmptyList.of(GroupId("group3"), GroupId("group4")) + )), + tokenHeader = bearerHeader(jwt) + ) + } + "rule groups with 'and' logic are defined and intersection between those groups and JWT ones is empty" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + "groups" := List("group1", "group2") + )) + assertNotMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) + ), + configuredGroups = GroupsLogic.AllOf(GroupIds( + UniqueNonEmptyList.of(GroupId("group2"), GroupId("group3")) + )), + tokenHeader = bearerHeader(jwt) + ) + } + "preferred group is not on the permitted groups list" in { + val key: Key = Jwts.SIG.HS256.key().build() + val jwt = Jwt(key, claims = List( + "userId" := "user1", + "groups" := List("group1", "group2") + )) + assertNotMatchRule( + configuredJwtDef = JwtDef( + JwtDef.Name("test"), + AuthorizationTokenDef(Header.Name.authorization, "Bearer "), + SignatureCheckMethod.Hmac(key.getEncoded), + userClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("userId"))), + groupsConfig = Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None)) + ), + configuredGroups = GroupsLogic.AnyOf(GroupIds( + UniqueNonEmptyList.of(GroupId("group2")) + )), + tokenHeader = bearerHeader(jwt), + preferredGroupId = Some(GroupId("group3")) + ) + } + } + } + + private def assertMatchRule(configuredJwtDef: JwtDef, + configuredGroups: GroupsLogic, + tokenHeader: Header, + preferredGroupId: Option[GroupId] = None) + (blockContextAssertion: BlockContext => Unit): Unit = + assertRule(configuredJwtDef, configuredGroups, tokenHeader, preferredGroupId, Some(blockContextAssertion)) + + private def assertNotMatchRule(configuredJwtDef: JwtDef, + configuredGroups: GroupsLogic, + tokenHeader: Header, + preferredGroupId: Option[GroupId] = None): Unit = + assertRule(configuredJwtDef, configuredGroups, tokenHeader, preferredGroupId, blockContextAssertion = None) + + private def assertRule(configuredJwtDef: JwtDef, + configuredGroups: GroupsLogic, + tokenHeader: Header, + preferredGroup: Option[GroupId], + blockContextAssertion: Option[BlockContext => Unit]) = { + val rule = new JwtAuthorizationRule(JwtAuthorizationRule.Settings(configuredJwtDef, configuredGroups)) + + val requestContext = MockRequestContext.indices.withHeaders( + preferredGroup.map(_.toCurrentGroupHeader).toSeq :+ tokenHeader + ) + val blockContext = GeneralIndexRequestBlockContext( + requestContext = requestContext, + userMetadata = UserMetadata.from(requestContext), + responseHeaders = Set.empty, + responseTransformations = List.empty, + filteredIndices = Set.empty, + allAllowedIndices = Set.empty + ) + val result = rule.check(blockContext).runSyncUnsafe(1 second) + blockContextAssertion match { + case Some(assertOutputBlockContext) => + inside(result) { case Fulfilled(outBlockContext) => + assertOutputBlockContext(outBlockContext) + } + case None => + result should be(Rejected()) + } + } +} diff --git a/core/src/test/scala/tech/beshu/ror/unit/acl/factory/LocalUsersTest.scala b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/LocalUsersTest.scala index e931d05d60..a3dc79074a 100644 --- a/core/src/test/scala/tech/beshu/ror/unit/acl/factory/LocalUsersTest.scala +++ b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/LocalUsersTest.scala @@ -255,8 +255,6 @@ class LocalUsersTest extends AnyWordSpec with Inside { core.rorConfig.localUsers should be(allUsersResolved(Set( User.Id("admin"), User.Id("cartman"), User.Id("Bìlbö Bággįnš"), User.Id("bong"), User.Id("morgan") ))) - case Left(error) => - println(error) } } "ror_kbn_authentication rule used" in { @@ -307,8 +305,6 @@ class LocalUsersTest extends AnyWordSpec with Inside { core.rorConfig.localUsers should be(allUsersResolved(Set( User.Id("admin"), User.Id("cartman"), User.Id("Bìlbö Bággįnš"), User.Id("bong"), User.Id("morgan") ))) - case Left(error) => - println(error) } } } diff --git a/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/JwtAuthRuleSettingsTests.scala b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/JwtAuthRuleSettingsTests.scala index 8a8b573863..446da843c6 100644 --- a/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/JwtAuthRuleSettingsTests.scala +++ b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/JwtAuthRuleSettingsTests.scala @@ -15,19 +15,19 @@ * along with ReadonlyREST. If not, see http://www.gnu.org/licenses/ */ package tech.beshu.ror.unit.acl.factory.decoders.rules.auth + import org.scalamock.scalatest.MockFactory import org.scalatest.matchers.should.Matchers.* import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef.{GroupsConfig, SignatureCheckMethod} import tech.beshu.ror.accesscontrol.blocks.definitions.{CacheableExternalAuthenticationServiceDecorator, JwtDef} -import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthRule -import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthRule.Groups +import tech.beshu.ror.accesscontrol.blocks.rules.auth.{JwtAuthRule, JwtAuthorizationRule, JwtPseudoAuthorizationRule} import tech.beshu.ror.accesscontrol.domain -import tech.beshu.ror.accesscontrol.domain.GroupIdLike.GroupId import tech.beshu.ror.accesscontrol.domain.* +import tech.beshu.ror.accesscontrol.domain.GroupIdLike.GroupId import tech.beshu.ror.accesscontrol.factory.HttpClientsFactory import tech.beshu.ror.accesscontrol.factory.HttpClientsFactory.HttpClient import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.Reason.{MalformedValue, Message} -import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.{DefinitionsLevelCreationError, GeneralReadonlyrestSettingsError, RulesLevelCreationError} +import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.{DefinitionsLevelCreationError, RulesLevelCreationError} import tech.beshu.ror.mocks.MockHttpClientsFactoryWithFixedHttpClient import tech.beshu.ror.providers.EnvVarProvider.EnvVarName import tech.beshu.ror.providers.EnvVarsProvider @@ -62,12 +62,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(None) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(None) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -90,12 +90,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(None) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(None) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -120,14 +120,14 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(None) - rule.settings.permittedGroups should be(Groups.Defined(GroupsLogic.AnyOf(GroupIds( + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(None) + rule.authorization.asInstanceOf[JwtAuthorizationRule].settings.groupsLogic should be(GroupsLogic.AnyOf(GroupIds( UniqueNonEmptyList.of(GroupIdLike.from("group1*"), GroupId("group2")) - )))) + ))) } ) } @@ -153,14 +153,14 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(None) - rule.settings.permittedGroups should be(Groups.Defined(GroupsLogic.AllOf(GroupIds( + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(None) + rule.authorization.asInstanceOf[JwtAuthorizationRule].settings.groupsLogic should be(GroupsLogic.AllOf(GroupIds( UniqueNonEmptyList.of(GroupIdLike.from("group1*"), GroupId("group2")) - )))) + ))) } ) } @@ -185,12 +185,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(headerNameFrom("X-JWT-Custom-Header"), "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(None) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(headerNameFrom("X-JWT-Custom-Header"), "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(None) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -214,12 +214,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(headerNameFrom("X-JWT-Custom-Header"), "MyPrefix ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(None) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(headerNameFrom("X-JWT-Custom-Header"), "MyPrefix ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(None) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -242,12 +242,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "MyPrefix ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(None) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "MyPrefix ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(None) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -271,12 +271,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(headerNameFrom("X-JWT-Custom-Header"), "")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(None) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(headerNameFrom("X-JWT-Custom-Header"), "")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(None) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -299,12 +299,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] - rule.settings.jwt.userClaim should be(Some(domain.Jwt.ClaimName(jsonPathFrom("user")))) - rule.settings.jwt.groupsConfig should be(None) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.authentication.settings.jwt.userClaim should be(Some(domain.Jwt.ClaimName(jsonPathFrom("user")))) + rule.authentication.settings.jwt.groupsConfig should be(None) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -329,12 +329,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a[SignatureCheckMethod.Hmac] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a[SignatureCheckMethod.Hmac] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -359,15 +359,15 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a[SignatureCheckMethod.Hmac] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(Some(GroupsConfig( + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a[SignatureCheckMethod.Hmac] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(Some(GroupsConfig( idsClaim = domain.Jwt.ClaimName(jsonPathFrom("groups")), namesClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("group_names"))) ))) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -390,12 +390,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("https://{domain}/claims/roles")), None))) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("https://{domain}/claims/roles")), None))) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -420,12 +420,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Rsa] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Rsa] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -449,12 +449,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Rsa] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Rsa] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -480,12 +480,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Rsa] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Rsa] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -510,12 +510,12 @@ class JwtAuthRuleSettingsTests | |""".stripMargin, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Ec] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Ec] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -544,13 +544,13 @@ class JwtAuthRuleSettingsTests |""".stripMargin, httpClientsFactory = mockedHttpClientsFactory, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.NoCheck] - rule.settings.jwt.checkMethod.asInstanceOf[SignatureCheckMethod.NoCheck].service shouldBe a[CacheableExternalAuthenticationServiceDecorator] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")),None))) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.NoCheck] + rule.authentication.settings.jwt.checkMethod.asInstanceOf[SignatureCheckMethod.NoCheck].service shouldBe a[CacheableExternalAuthenticationServiceDecorator] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")),None))) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -582,13 +582,13 @@ class JwtAuthRuleSettingsTests |""".stripMargin, httpClientsFactory = mockedHttpClientsFactory, assertion = rule => { - rule.settings.jwt.id should be(JwtDef.Name("jwt1")) - rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) - rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.NoCheck] - rule.settings.jwt.checkMethod.asInstanceOf[SignatureCheckMethod.NoCheck].service shouldBe a[CacheableExternalAuthenticationServiceDecorator] - rule.settings.jwt.userClaim should be(None) - rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")),None))) - rule.settings.permittedGroups should be(Groups.NotDefined) + rule.authentication.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.authentication.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.authentication.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.NoCheck] + rule.authentication.settings.jwt.checkMethod.asInstanceOf[SignatureCheckMethod.NoCheck].service shouldBe a[CacheableExternalAuthenticationServiceDecorator] + rule.authentication.settings.jwt.userClaim should be(None) + rule.authentication.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")),None))) + rule.authorization.asInstanceOf[JwtPseudoAuthorizationRule] } ) } @@ -799,153 +799,6 @@ class JwtAuthRuleSettingsTests } ) } - "no signature key is defined for default HMAC algorithm" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | jwt_auth: jwt1 - | - | jwt: - | - | - name: jwt1 - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(MalformedValue.fromString( - """- name: "jwt1" - |""".stripMargin - ))) - } - ) - } - "RSA algorithm is defined but on signature key" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | jwt_auth: jwt1 - | - | jwt: - | - | - name: jwt1 - | signature_algo: "RSA" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(MalformedValue.fromString( - """- name: "jwt1" - | signature_algo: "RSA" - |""".stripMargin - ))) - } - ) - } - "unrecognized algorithm is used" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | jwt_auth: jwt1 - | - | jwt: - | - | - name: jwt1 - | signature_algo: "UNKNOWN" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(Message("Unrecognised algorithm family 'UNKNOWN'. Should be either of: HMAC, EC, RSA, NONE"))) - } - ) - } - "RSA signature key is malformed" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | jwt_auth: jwt1 - | - | jwt: - | - | - name: jwt1 - | signature_algo: "RSA" - | signature_key: "malformed_key" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(Message("Key 'malformed_key' seems to be invalid"))) - } - ) - } - "RSA signature key cannot be read from system env" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | jwt_auth: jwt1 - | - | jwt: - | - | - name: jwt1 - | signature_algo: "RSA" - | signature_key: "@{env:SECRET}" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(GeneralReadonlyrestSettingsError(Message("Cannot resolve ENV variable 'SECRET'"))) - } - ) - } - "EC signature key is malformed" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | jwt_auth: jwt1 - | - | jwt: - | - | - name: jwt1 - | signature_algo: "EC" - | signature_key: "malformed_key" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(Message("Key 'malformed_key' seems to be invalid"))) - } - ) - } "no signature check is used but required external validation service is not defined" in { assertDecodingFailure( yaml = diff --git a/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/JwtAuthenticationRuleSettingsTests.scala b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/JwtAuthenticationRuleSettingsTests.scala new file mode 100644 index 0000000000..f8deaa4832 --- /dev/null +++ b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/JwtAuthenticationRuleSettingsTests.scala @@ -0,0 +1,632 @@ +/* + * This file is part of ReadonlyREST. + * + * ReadonlyREST is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ReadonlyREST is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ReadonlyREST. If not, see http://www.gnu.org/licenses/ + */ +package tech.beshu.ror.unit.acl.factory.decoders.rules.auth + +import org.scalamock.scalatest.MockFactory +import org.scalatest.matchers.should.Matchers.* +import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef.{GroupsConfig, SignatureCheckMethod} +import tech.beshu.ror.accesscontrol.blocks.definitions.{CacheableExternalAuthenticationServiceDecorator, JwtDef} +import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthenticationRule +import tech.beshu.ror.accesscontrol.domain +import tech.beshu.ror.accesscontrol.domain.* +import tech.beshu.ror.accesscontrol.factory.HttpClientsFactory +import tech.beshu.ror.accesscontrol.factory.HttpClientsFactory.HttpClient +import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.Reason.{MalformedValue, Message} +import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.{DefinitionsLevelCreationError, GeneralReadonlyrestSettingsError, RulesLevelCreationError} +import tech.beshu.ror.mocks.MockHttpClientsFactoryWithFixedHttpClient +import tech.beshu.ror.providers.EnvVarProvider.EnvVarName +import tech.beshu.ror.providers.EnvVarsProvider +import tech.beshu.ror.unit.acl.factory.decoders.rules.BaseRuleSettingsDecoderTest +import tech.beshu.ror.utils.TestsUtils.* + +import java.security.KeyPairGenerator +import java.util.Base64 + +class JwtAuthenticationRuleSettingsTests + extends BaseRuleSettingsDecoderTest[JwtAuthenticationRule] + with MockFactory { + + "A JwtAuthenticationRule" should { + "be able to be loaded from config" when { + "rule is defined using simplified version and minimal required set of fields in JWT definition" in { + assertDecodingSuccess( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(None) + } + ) + } + "rule is defined using extended version and minimal request set of fields in JWT definition" in { + assertDecodingSuccess( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: + | name: "jwt1" + | + | jwt: + | + | - name: jwt1 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(None) + } + ) + } + "user claim can be enabled in JWT definition" in { + assertDecodingSuccess( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | user_claim: user + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.settings.jwt.userClaim should be(Some(domain.Jwt.ClaimName(jsonPathFrom("user")))) + rule.settings.jwt.groupsConfig should be(None) + } + ) + } + "group IDs claim can be enabled in JWT definition" in { + val claimKeys = List("roles_claim", "groups_claim", "group_ids_claim") + claimKeys.foreach { claimKey => + assertDecodingSuccess( + yaml = + s""" + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | $claimKey: groups + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a[SignatureCheckMethod.Hmac] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) + } + ) + } + } + "group names claim can be enabled in JWT definition" in { + assertDecodingSuccess( + yaml = + s""" + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | group_ids_claim: groups + | group_names_claim: group_names + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a[SignatureCheckMethod.Hmac] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(Some(GroupsConfig( + idsClaim = domain.Jwt.ClaimName(jsonPathFrom("groups")), + namesClaim = Some(domain.Jwt.ClaimName(jsonPathFrom("group_names"))) + ))) + } + ) + } + "groups claim can be enabled in JWT definition and is a http address" in { + assertDecodingSuccess( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | group_ids_claim: "https://{domain}/claims/roles" + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Hmac] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("https://{domain}/claims/roles")), None))) + } + ) + } + } + "be able to be loaded from config (token-related)" when { + "RSA family algorithm can be used in JWT signature" in { + val pkey = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic + assertDecodingSuccess( + yaml = + s""" + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | group_ids_claim: groups + | signature_algo: "RSA" + | signature_key: "${Base64.getEncoder.encodeToString(pkey.getEncoded)}" + | + |""".stripMargin, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Rsa] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) + } + ) + } + "RSA family algorithm can be used in JWT signature and key is being read from system env in old format" in { + assertDecodingSuccess( + yaml = + s""" + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | group_ids_claim: groups + | signature_algo: "RSA" + | signature_key: "env:SECRET_RSA" + | + |""".stripMargin, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Rsa] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) + } + ) + } + "RSA family algorithm can be used in JWT signature and key is being read from system env in new format" in { + val pkey = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic + System.setProperty("SECRET_KEY", Base64.getEncoder.encodeToString(pkey.getEncoded)) + assertDecodingSuccess( + yaml = + s""" + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | group_ids_claim: groups + | signature_algo: "RSA" + | signature_key: "@{env:SECRET_RSA}" + | + |""".stripMargin, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Rsa] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) + } + ) + } + "EC family algorithm can be used in JWT signature" in { + val pkey = KeyPairGenerator.getInstance("EC").generateKeyPair().getPublic + assertDecodingSuccess( + yaml = + s""" + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | group_ids_claim: groups + | signature_algo: "EC" + | signature_key: "text: ${Base64.getEncoder.encodeToString(pkey.getEncoded)}" + | + |""".stripMargin, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.Ec] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")), None))) + } + ) + } + "None signature check can be used in JWT definition" in { + assertDecodingSuccess( + yaml = + s""" + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | group_ids_claim: groups + | signature_algo: "NONE" + | external_validator: + | url: "http://192.168.0.1:8080/jwt" + | success_status_code: 204 + | cache_ttl_in_sec: 60 + | validate: false + | + |""".stripMargin, + httpClientsFactory = mockedHttpClientsFactory, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.NoCheck] + rule.settings.jwt.checkMethod.asInstanceOf[SignatureCheckMethod.NoCheck].service shouldBe a[CacheableExternalAuthenticationServiceDecorator] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")),None))) + } + ) + } + "None signature check can be used in JWT definition with custom http client settings for external validator" in { + assertDecodingSuccess( + yaml = + s""" + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | group_ids_claim: groups + | signature_algo: "NONE" + | external_validator: + | url: "http://192.168.0.1:8080/jwt" + | success_status_code: 204 + | cache_ttl_in_sec: 60 + | http_connection_settings: + | connection_timeout_in_sec: 1 + | connection_request_timeout_in_sec: 10 + | connection_pool_size: 30 + | validate: true + |""".stripMargin, + httpClientsFactory = mockedHttpClientsFactory, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a [SignatureCheckMethod.NoCheck] + rule.settings.jwt.checkMethod.asInstanceOf[SignatureCheckMethod.NoCheck].service shouldBe a[CacheableExternalAuthenticationServiceDecorator] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(Some(GroupsConfig(domain.Jwt.ClaimName(jsonPathFrom("groups")),None))) + } + ) + } + } + "not be able to be loaded from config" when { + "no JWT definition name is defined in rule setting" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: + | + | jwt: + | + | - name: jwt1 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(RulesLevelCreationError(MalformedValue.fromString( + """jwt_authentication: null + |""".stripMargin + ))) + } + ) + } + "JWT definition with given name is not found" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt2 + | + | jwt: + | + | - name: jwt1 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(RulesLevelCreationError(Message("Cannot find JWT definition with name: jwt2"))) + } + ) + } + "no JWT definition is defined" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(RulesLevelCreationError(Message("Cannot find JWT definition with name: jwt1"))) + } + ) + } + "no signature key is defined for default HMAC algorithm" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(DefinitionsLevelCreationError(MalformedValue.fromString( + """- name: "jwt1" + |""".stripMargin + ))) + } + ) + } + "RSA algorithm is defined but on signature key" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | signature_algo: "RSA" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(DefinitionsLevelCreationError(MalformedValue.fromString( + """- name: "jwt1" + | signature_algo: "RSA" + |""".stripMargin + ))) + } + ) + } + "unrecognized algorithm is used" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | signature_algo: "UNKNOWN" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(DefinitionsLevelCreationError(Message("Unrecognised algorithm family 'UNKNOWN'. Should be either of: HMAC, EC, RSA, NONE"))) + } + ) + } + "RSA signature key is malformed" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | signature_algo: "RSA" + | signature_key: "malformed_key" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(DefinitionsLevelCreationError(Message("Key 'malformed_key' seems to be invalid"))) + } + ) + } + "RSA signature key cannot be read from system env" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | signature_algo: "RSA" + | signature_key: "@{env:SECRET}" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(GeneralReadonlyrestSettingsError(Message("Cannot resolve ENV variable 'SECRET'"))) + } + ) + } + "EC signature key is malformed" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authentication: jwt1 + | + | jwt: + | + | - name: jwt1 + | signature_algo: "EC" + | signature_key: "malformed_key" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(DefinitionsLevelCreationError(Message("Key 'malformed_key' seems to be invalid"))) + } + ) + } + } + } + + override implicit protected def envVarsProvider: EnvVarsProvider = { + case EnvVarName(env) if env.value == "SECRET_RSA" => + val pkey = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic + Some(Base64.getEncoder.encodeToString(pkey.getEncoded)) + case _ => + None + } + + private val mockedHttpClientsFactory: HttpClientsFactory = { + val httpClientMock = mock[HttpClient] + new MockHttpClientsFactoryWithFixedHttpClient(httpClientMock) + } +} diff --git a/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/JwtAuthorizationRuleSettingsTests.scala b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/JwtAuthorizationRuleSettingsTests.scala new file mode 100644 index 0000000000..28b5785b53 --- /dev/null +++ b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/JwtAuthorizationRuleSettingsTests.scala @@ -0,0 +1,335 @@ +/* + * This file is part of ReadonlyREST. + * + * ReadonlyREST is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ReadonlyREST is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ReadonlyREST. If not, see http://www.gnu.org/licenses/ + */ +package tech.beshu.ror.unit.acl.factory.decoders.rules.auth + +import org.scalamock.scalatest.MockFactory +import org.scalatest.matchers.should.Matchers.* +import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef +import tech.beshu.ror.accesscontrol.blocks.definitions.JwtDef.SignatureCheckMethod +import tech.beshu.ror.accesscontrol.blocks.rules.auth.JwtAuthorizationRule +import tech.beshu.ror.accesscontrol.domain.* +import tech.beshu.ror.accesscontrol.domain.GroupIdLike.GroupId +import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.Reason.{MalformedValue, Message} +import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.{DefinitionsLevelCreationError, RulesLevelCreationError} +import tech.beshu.ror.providers.EnvVarProvider.EnvVarName +import tech.beshu.ror.providers.EnvVarsProvider +import tech.beshu.ror.unit.acl.factory.decoders.rules.BaseRuleSettingsDecoderTest +import tech.beshu.ror.utils.TestsUtils.* +import tech.beshu.ror.utils.uniquelist.UniqueNonEmptyList + +import java.security.KeyPairGenerator +import java.util.Base64 + +class JwtAuthorizationRuleSettingsTests + extends BaseRuleSettingsDecoderTest[JwtAuthorizationRule] + with MockFactory { + + "A JwtAuthorizationRule" should { + "be able to be loaded from config" when { + "rule is defined using extended version with groups 'or' logic and minimal request set of fields in JWT definition" in { + val ruleKeys = List("roles", "groups", "groups_or") + ruleKeys.foreach { ruleKey => + assertDecodingSuccess( + yaml = + s""" + |readonlyrest: + | access_control_rules: + | + | - name: test_block1 + | auth_key_sha1: "d27aaf7fa3c1603948bb29b7339f2559dc02019a" + | jwt_authorization: + | name: "jwt1" + | $ruleKey: ["group1*","group2"] + | + | jwt: + | + | - name: jwt1 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a[SignatureCheckMethod.Hmac] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(None) + rule.settings.groupsLogic should be(GroupsLogic.AnyOf(GroupIds( + UniqueNonEmptyList.of(GroupIdLike.from("group1*"), GroupId("group2")) + ))) + } + ) + } + } + "rule is defined using extended version with groups 'and' logic and minimal request set of fields in JWT definition" in { + val ruleKeys = List("roles_and", "groups_and") + ruleKeys.foreach { ruleKey => + assertDecodingSuccess( + yaml = + s""" + |readonlyrest: + | access_control_rules: + | + | - name: test_block1 + | auth_key_sha1: "d27aaf7fa3c1603948bb29b7339f2559dc02019a" + | jwt_authorization: + | name: "jwt1" + | $ruleKey: ["group1*","group2"] + | + | jwt: + | + | - name: jwt1 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = rule => { + rule.settings.jwt.id should be(JwtDef.Name("jwt1")) + rule.settings.jwt.authorizationTokenDef should be(AuthorizationTokenDef(Header.Name.authorization, "Bearer ")) + rule.settings.jwt.checkMethod shouldBe a[SignatureCheckMethod.Hmac] + rule.settings.jwt.userClaim should be(None) + rule.settings.jwt.groupsConfig should be(None) + rule.settings.groupsLogic should be(GroupsLogic.AllOf(GroupIds( + UniqueNonEmptyList.of(GroupIdLike.from("group1*"), GroupId("group2")) + ))) + } + ) + } + } + } + "not be able to be loaded from config" when { + "no JWT definition name is defined in rule setting" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authorization: + | + | jwt: + | + | - name: jwt1 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(RulesLevelCreationError(MalformedValue.fromString( + """jwt_authorization: null + |""".stripMargin + ))) + } + ) + } + "JWT definition with given name is not found" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authorization: jwt2 + | + | jwt: + | + | - name: jwt1 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(RulesLevelCreationError(Message("Cannot find JWT definition with name: jwt2"))) + } + ) + } + "no JWT definition is defined" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authorization: jwt1 + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(RulesLevelCreationError(Message("Cannot find JWT definition with name: jwt1"))) + } + ) + } + "extended version of rule settings is used, but no JWT definition name attribute is used" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authorization: + | roles: ["group1","group2"] + | + | jwt: + | + | - name: jwt1 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(RulesLevelCreationError(MalformedValue.fromString( + """jwt_authorization: + | roles: + | - "group1" + | - "group2" + |""".stripMargin + ))) + } + ) + } + "extended version of rule settings is used, but both 'groups or' key and 'groups and' key used" in { + List( + ("roles", "roles_and"), + ("groups", "groups_and") + ) + .foreach { case (groupsAnyOfKey, groupsAllOfKey) => + assertDecodingFailure( + yaml = + s""" + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authorization: + | name: "jwt1" + | $groupsAnyOfKey: ["group1","group2"] + | $groupsAllOfKey: ["group1","group2"] + | + | jwt: + | + | - name: jwt1 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(RulesLevelCreationError(Message( + s"Please specify either '$groupsAnyOfKey' or '$groupsAllOfKey' for JWT authorization rule 'jwt1'" + ))) + } + ) + } + } + "no JWT definition name is defined" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authorization: jwt1 + | + | jwt: + | - signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(DefinitionsLevelCreationError(MalformedValue.fromString( + """- signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + |""".stripMargin + ))) + } + ) + } + "both 'groups or' key and 'groups and' key used" in { + List( + ("roles", "roles_and"), + ("groups", "groups_and") + ) + .foreach { case (groupsAnyOfKey, groupsAllOfKey) => + assertDecodingFailure( + yaml = + s""" + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authorization: + | name: "jwt1" + | $groupsAnyOfKey: ["group1", "group2"] + | $groupsAllOfKey: ["groups1", "groups2"] + | jwt: + | - name: jwt2 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(RulesLevelCreationError(Message( + s"Please specify either '$groupsAnyOfKey' or '$groupsAllOfKey' for JWT authorization rule 'jwt1'") + )) + } + ) + } + } + "two JWT definitions have the same names" in { + assertDecodingFailure( + yaml = + """ + |readonlyrest: + | + | access_control_rules: + | + | - name: test_block1 + | jwt_authorization: jwt1 + | + | jwt: + | + | - name: jwt1 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + | - name: jwt1 + | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" + | + |""".stripMargin, + assertion = errors => { + errors should have size 1 + errors.head should be(DefinitionsLevelCreationError(Message("jwt definitions must have unique identifiers. Duplicates: jwt1"))) + } + ) + } + } + } + + override implicit protected def envVarsProvider: EnvVarsProvider = { + case EnvVarName(env) if env.value == "SECRET_RSA" => + val pkey = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic + Some(Base64.getEncoder.encodeToString(pkey.getEncoded)) + case _ => + None + } +} diff --git a/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/RorKbnAuthRuleSettingsTests.scala b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/RorKbnAuthRuleSettingsTests.scala index deaaceda7a..7e9a293833 100644 --- a/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/RorKbnAuthRuleSettingsTests.scala +++ b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/RorKbnAuthRuleSettingsTests.scala @@ -23,7 +23,7 @@ import tech.beshu.ror.accesscontrol.blocks.rules.auth.RorKbnAuthRule import tech.beshu.ror.accesscontrol.domain.GroupIdLike.GroupId import tech.beshu.ror.accesscontrol.domain.{GroupIdLike, GroupIds, GroupsLogic} import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.Reason.{MalformedValue, Message} -import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.{DefinitionsLevelCreationError, GeneralReadonlyrestSettingsError, RulesLevelCreationError} +import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.{DefinitionsLevelCreationError, RulesLevelCreationError} import tech.beshu.ror.providers.EnvVarProvider.EnvVarName import tech.beshu.ror.providers.EnvVarsProvider import tech.beshu.ror.unit.acl.factory.decoders.rules.BaseRuleSettingsDecoderTest @@ -257,7 +257,7 @@ class RorKbnAuthRuleSettingsTests assertion = errors => { errors should have size 1 errors.head should be(RulesLevelCreationError(Message( - s"Please specify either '$groupsAnyOfKey' or '$groupsAllOfKey' for ROR Kibana rule 'kbn1'") + s"Please specify either '$groupsAnyOfKey' or '$groupsAllOfKey' for ROR Kibana authorization rule 'kbn1'") )) } ) @@ -289,153 +289,6 @@ class RorKbnAuthRuleSettingsTests } ) } - "no signature key is defined for default HMAC algorithm" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_auth: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(MalformedValue.fromString( - """- name: "kbn1" - |""".stripMargin - ))) - } - ) - } - "RSA algorithm is defined but on signature key" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_auth: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | signature_algo: "RSA" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(MalformedValue.fromString( - """- name: "kbn1" - | signature_algo: "RSA" - |""".stripMargin - ))) - } - ) - } - "unrecognized algorithm is used" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_auth: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | signature_algo: "UNKNOWN" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(Message("Unrecognised algorithm family 'UNKNOWN'. Should be either of: HMAC, EC, RSA, NONE"))) - } - ) - } - "RSA signature key is malformed" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_auth: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | signature_algo: "RSA" - | signature_key: "malformed_key" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(Message("Key 'malformed_key' seems to be invalid"))) - } - ) - } - "RSA signature key cannot be read from system env" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_auth: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | signature_algo: "RSA" - | signature_key: "@{env:SECRET}" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(GeneralReadonlyrestSettingsError(Message("Cannot resolve ENV variable 'SECRET'"))) - } - ) - } - "EC signature key is malformed" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_auth: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | signature_algo: "EC" - | signature_key: "malformed_key" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(Message("Key 'malformed_key' seems to be invalid"))) - } - ) - } } } diff --git a/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/RorKbnAuthenticationRuleSettingsTests.scala b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/RorKbnAuthenticationRuleSettingsTests.scala index 2ffc8a5a44..b79f810c2b 100644 --- a/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/RorKbnAuthenticationRuleSettingsTests.scala +++ b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/RorKbnAuthenticationRuleSettingsTests.scala @@ -82,6 +82,8 @@ class RorKbnAuthenticationRuleSettingsTests } ) } + } + "be able to be loaded from config (token-related)" when { "RSA family algorithm can be used in token signature" in { val pkey = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic assertDecodingSuccess( @@ -360,7 +362,7 @@ class RorKbnAuthenticationRuleSettingsTests assertion = errors => { errors should have size 1 errors.head should be(RulesLevelCreationError(Message( - s"Please specify either '$groupsAnyOfKey' or '$groupsAllOfKey' for ROR Kibana rule 'kbn1'") + s"Please specify either '$groupsAnyOfKey' or '$groupsAllOfKey' for ROR Kibana authorization rule 'kbn1'") )) } ) @@ -392,6 +394,8 @@ class RorKbnAuthenticationRuleSettingsTests } ) } + } + "not be able to be loaded from config (token-related)" when { "no signature key is defined for default HMAC algorithm" in { assertDecodingFailure( yaml = diff --git a/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/RorKbnAuthorizationRuleSettingsTests.scala b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/RorKbnAuthorizationRuleSettingsTests.scala index 52bb7fd7a3..8fc61d2c1b 100644 --- a/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/RorKbnAuthorizationRuleSettingsTests.scala +++ b/core/src/test/scala/tech/beshu/ror/unit/acl/factory/decoders/rules/auth/RorKbnAuthorizationRuleSettingsTests.scala @@ -23,7 +23,7 @@ import tech.beshu.ror.accesscontrol.blocks.rules.auth.RorKbnAuthorizationRule import tech.beshu.ror.accesscontrol.domain.GroupIdLike.GroupId import tech.beshu.ror.accesscontrol.domain.{GroupIdLike, GroupIds, GroupsLogic} import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.Reason.{MalformedValue, Message} -import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.{DefinitionsLevelCreationError, GeneralReadonlyrestSettingsError, RulesLevelCreationError} +import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.{DefinitionsLevelCreationError, RulesLevelCreationError} import tech.beshu.ror.providers.EnvVarProvider.EnvVarName import tech.beshu.ror.providers.EnvVarsProvider import tech.beshu.ror.unit.acl.factory.decoders.rules.BaseRuleSettingsDecoderTest @@ -256,185 +256,12 @@ class RorKbnAuthorizationRuleSettingsTests assertion = errors => { errors should have size 1 errors.head should be(RulesLevelCreationError(Message( - s"Please specify either '$groupsAnyOfKey' or '$groupsAllOfKey' for ROR Kibana rule 'kbn1'") + s"Please specify either '$groupsAnyOfKey' or '$groupsAllOfKey' for ROR Kibana authorization rule 'kbn1'") )) } ) } } - "two ROR kbn definitions have the same names" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_authorization: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" - | - | - name: kbn1 - | signature_key: "123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456.123456" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(Message("ror_kbn definitions must have unique identifiers. Duplicates: kbn1"))) - } - ) - } - "no signature key is defined for default HMAC algorithm" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_authorization: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(MalformedValue.fromString( - """- name: "kbn1" - |""".stripMargin - ))) - } - ) - } - "RSA algorithm is defined but on signature key" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_authorization: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | signature_algo: "RSA" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(MalformedValue.fromString( - """- name: "kbn1" - | signature_algo: "RSA" - |""".stripMargin - ))) - } - ) - } - "unrecognized algorithm is used" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_authorization: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | signature_algo: "UNKNOWN" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(Message("Unrecognised algorithm family 'UNKNOWN'. Should be either of: HMAC, EC, RSA, NONE"))) - } - ) - } - "RSA signature key is malformed" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_authorization: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | signature_algo: "RSA" - | signature_key: "malformed_key" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(Message("Key 'malformed_key' seems to be invalid"))) - } - ) - } - "RSA signature key cannot be read from system env" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_authorization: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | signature_algo: "RSA" - | signature_key: "@{env:SECRET}" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(GeneralReadonlyrestSettingsError(Message("Cannot resolve ENV variable 'SECRET'"))) - } - ) - } - "EC signature key is malformed" in { - assertDecodingFailure( - yaml = - """ - |readonlyrest: - | - | access_control_rules: - | - | - name: test_block1 - | ror_kbn_authorization: kbn1 - | - | ror_kbn: - | - | - name: kbn1 - | signature_algo: "EC" - | signature_key: "malformed_key" - | - |""".stripMargin, - assertion = errors => { - errors should have size 1 - errors.head should be(DefinitionsLevelCreationError(Message("Key 'malformed_key' seems to be invalid"))) - } - ) - } } }