Skip to content

Commit

Permalink
Merge pull request #2394 from constantine2nd/develop
Browse files Browse the repository at this point in the history
OBP Consent Flow
  • Loading branch information
simonredfern committed Jun 14, 2024
2 parents ffc4e7d + e39e469 commit f1303bf
Show file tree
Hide file tree
Showing 14 changed files with 132 additions and 63 deletions.
6 changes: 5 additions & 1 deletion obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,11 @@ database_messages_scheduler_interval=3600
# -- Consents ---------------------------------------------
# In case isn't defined default value is "false"
# consents.allowed=true
# consumer_validation_method_for_consent=CONSUMER_KEY_VALUE
#
# In order to pin a consent to a consumer we can use the property consumer_validation_method_for_consent
# Possibile values are: CONSUMER_CERTIFICATE, CONSUMER_KEY_VALUE, NONE
# consumer_validation_method_for_consent=CONSUMER_CERTIFICATE
#
# consents.max_time_to_live=3600
# In case isn't defined default value is "false"
# consents.sca.enabled=true
Expand Down
10 changes: 8 additions & 2 deletions obp-api/src/main/scala/code/api/util/APIUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2971,8 +2971,14 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
} else if (APIUtil.hasConsentJWT(reqHeaders)) { // Open Bank Project's Consent
val consentValue = APIUtil.getConsentJWT(reqHeaders)
Consent.getConsentJwtValueByConsentId(consentValue.getOrElse("")) match {
case Some(jwt) => // JWT value obtained via "Consent-Id" request header
Consent.applyRules(Some(jwt), cc)
case Some(consent) => // JWT value obtained via "Consent-Id" request header
Consent.applyRules(
Some(consent.jsonWebToken),
// Note: At this point we are getting the Consumer from the Consumer in the Consent.
// This may later be cross checked via the value in consumer_validation_method_for_consent.
// TODO: Get the source of truth for Consumer (e.g. CONSUMER_CERTIFICATE) as early as possible.
cc.copy(consumer = Consumers.consumers.vend.getConsumerByConsumerId(consent.consumerId))
)
case _ =>
JwtUtil.checkIfStringIsJWTValue(consentValue.getOrElse("")).isDefined match {
case true => // It's JWT obtained via "Consent-JWT" request header
Expand Down
67 changes: 34 additions & 33 deletions obp-api/src/main/scala/code/api/util/ConsentUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import code.context.{ConsentAuthContextProvider, UserAuthContextProvider}
import code.entitlement.Entitlement
import code.model.Consumer
import code.users.Users
import code.util.Helper.MdcLoggable
import code.util.HydraUtil
import code.views.Views
import com.nimbusds.jwt.JWTClaimsSet
Expand All @@ -25,7 +26,7 @@ import net.liftweb.http.provider.HTTPParam
import net.liftweb.json.JsonParser.ParseException
import net.liftweb.json.{Extraction, MappingException, compactRender, parse}
import net.liftweb.mapper.By
import net.liftweb.util.ControlHelpers
import net.liftweb.util.{ControlHelpers, Props}
import sh.ory.hydra.model.OAuth2TokenIntrospection

import scala.collection.immutable.{List, Nil}
Expand Down Expand Up @@ -104,7 +105,7 @@ case class Consent(createdByUserId: String,
}
}

object Consent {
object Consent extends MdcLoggable {

final lazy val challengeAnswerAtTestEnvironment = "123"

Expand All @@ -126,7 +127,11 @@ object Consent {
private def checkConsumerIsActiveAndMatched(consent: ConsentJWT, callContext: CallContext): Box[Boolean] = {
Consumers.consumers.vend.getConsumerByConsumerId(consent.aud) match {
case Full(consumerFromConsent) if consumerFromConsent.isActive.get == true => // Consumer is active
APIUtil.getPropsValue(nameOfProperty = "consumer_validation_method_for_consent", defaultValue = "CONSUMER_KEY_VALUE") match {
val validationMetod = APIUtil.getPropsValue(nameOfProperty = "consumer_validation_method_for_consent", defaultValue = "CONSUMER_CERTIFICATE")
if(validationMetod != "CONSUMER_CERTIFICATE" && Props.mode == Props.RunModes.Production) {
logger.warn(s"consumer_validation_method_for_consent is not set to CONSUMER_CERTIFICATE! The current value is: ${validationMetod}")
}
validationMetod match {
case "CONSUMER_KEY_VALUE" =>
val requestHeaderConsumerKey = getConsumerKey(callContext.requestHeaders)
requestHeaderConsumerKey match {
Expand Down Expand Up @@ -272,7 +277,7 @@ object Consent {
if (result.forall(_ == "Added")) Full(user) else Failure("Cannot add permissions to the user with id: " + user.userId)
}

private def hasConsentInternalOldStyle(consentIdAsJwt: String, calContext: CallContext): Box[User] = {
private def applyConsentRulesCommonOldStyle(consentIdAsJwt: String, calContext: CallContext): Box[User] = {
implicit val dateFormats = CustomJsonFormats.formats

def applyConsentRules(consent: ConsentJWT): Box[User] = {
Expand Down Expand Up @@ -316,7 +321,7 @@ object Consent {
}
}

private def hasConsentCommon(consentAsJwt: String, callContext: CallContext): Future[(Box[User], Option[CallContext])] = {
private def applyConsentRulesCommon(consentAsJwt: String, callContext: CallContext): Future[(Box[User], Option[CallContext])] = {
implicit val dateFormats = CustomJsonFormats.formats

def applyConsentRules(consent: ConsentJWT): Future[(Box[User], Option[CallContext])] = {
Expand Down Expand Up @@ -351,13 +356,16 @@ object Consent {
case Full(jsonAsString) =>
try {
val consent = net.liftweb.json.parse(jsonAsString).extract[ConsentJWT]
checkConsent(consent, consentAsJwt, callContext) match { // Check is it Consent-JWT expired
// Set Consumer into Call Context
val consumer = Consumers.consumers.vend.getConsumerByConsumerId(consent.aud)
val updatedCallContext = callContext.copy(consumer = consumer)
checkConsent(consent, consentAsJwt, updatedCallContext) match { // Check is it Consent-JWT expired
case (Full(true)) => // OK
applyConsentRules(consent)
case failure@Failure(_, _, _) => // Handled errors
Future(failure, Some(callContext))
Future(failure, Some(updatedCallContext))
case _ => // Unexpected errors
Future(Failure(ErrorMessages.ConsentCheckExpiredIssue), Some(callContext))
Future(Failure(ErrorMessages.ConsentCheckExpiredIssue), Some(updatedCallContext))
}
} catch { // Possible exceptions
case e: ParseException => Future(Failure("ParseException: " + e.getMessage), Some(callContext))
Expand All @@ -371,27 +379,20 @@ object Consent {
}
}

private def hasConsentOldStyle(consentIdAsJwt: String, callContext: CallContext): (Box[User], CallContext) = {
(hasConsentInternalOldStyle(consentIdAsJwt, callContext), callContext)
}
private def hasConsent(consentAsJwt: String, callContext: CallContext): Future[(Box[User], Option[CallContext])] = {
hasConsentCommon(consentAsJwt, callContext)
}

def applyRules(consentJwt: Option[String], callContext: CallContext): Future[(Box[User], Option[CallContext])] = {
val allowed = APIUtil.getPropsAsBoolValue(nameOfProperty="consents.allowed", defaultValue=false)
(consentJwt, allowed) match {
case (Some(consentId), true) => hasConsent(consentId, callContext)
case (Some(jwt), true) => applyConsentRulesCommon(jwt, callContext)
case (_, false) => Future((Failure(ErrorMessages.ConsentDisabled), Some(callContext)))
case (None, _) => Future((Failure(ErrorMessages.ConsentHeaderNotFound), Some(callContext)))
}
}

def getConsentJwtValueByConsentId(consentId: String): Option[String] = {
def getConsentJwtValueByConsentId(consentId: String): Option[MappedConsent] = {
APIUtil.checkIfStringIsUUIDVersion1(consentId) match {
case true => // String is a UUID
Consents.consentProvider.vend.getConsentByConsentId(consentId) match {
case Full(consent) => Some(consent.jsonWebToken)
case Full(consent) => Some(consent)
case _ => None // It's not valid UUID value
}
case false => None // It's not UUID at all
Expand All @@ -407,7 +408,7 @@ object Consent {
Full(Nil)
}
}
private def hasBerlinGroupConsentInternal(consentId: String, callContext: CallContext): Future[(Box[User], Option[CallContext])] = {
private def applyBerlinGroupConsentRulesCommon(consentId: String, callContext: CallContext): Future[(Box[User], Option[CallContext])] = {
implicit val dateFormats = CustomJsonFormats.formats

def applyConsentRules(consent: ConsentJWT): Future[(Box[User], Option[CallContext])] = {
Expand Down Expand Up @@ -464,57 +465,57 @@ object Consent {
// 1st we need to find a Consent via the field MappedConsent.consentId
Consents.consentProvider.vend.getConsentByConsentId(consentId) match {
case Full(storedConsent) =>
// Set Consumer into Call Context
val consumer = Consumers.consumers.vend.getConsumerByConsumerId(storedConsent.consumerId)
val updatedCallContext = callContext.copy(consumer = consumer)
// This function MUST be called only once per call. I.e. it's date dependent
val (canBeUsed, currentCounterState) = checkFrequencyPerDay(storedConsent)
if(canBeUsed) {
JwtUtil.getSignedPayloadAsJson(storedConsent.jsonWebToken) match {
case Full(jsonAsString) =>
try {
val consent = net.liftweb.json.parse(jsonAsString).extract[ConsentJWT]
checkConsent(consent, storedConsent.jsonWebToken, callContext) match { // Check is it Consent-JWT expired
checkConsent(consent, storedConsent.jsonWebToken, updatedCallContext) match { // Check is it Consent-JWT expired
case (Full(true)) => // OK
// Update MappedConsent.usesSoFarTodayCounter field
Consents.consentProvider.vend.updateBerlinGroupConsent(consentId, currentCounterState + 1)
applyConsentRules(consent)
case failure@Failure(_, _, _) => // Handled errors
Future(failure, Some(callContext))
Future(failure, Some(updatedCallContext))
case _ => // Unexpected errors
Future(Failure(ErrorMessages.ConsentCheckExpiredIssue), Some(callContext))
Future(Failure(ErrorMessages.ConsentCheckExpiredIssue), Some(updatedCallContext))
}
} catch { // Possible exceptions
case e: ParseException => Future(Failure("ParseException: " + e.getMessage), Some(callContext))
case e: MappingException => Future(Failure("MappingException: " + e.getMessage), Some(callContext))
case e: Exception => Future(Failure("parsing failed: " + e.getMessage), Some(callContext))
case e: ParseException => Future(Failure("ParseException: " + e.getMessage), Some(updatedCallContext))
case e: MappingException => Future(Failure("MappingException: " + e.getMessage), Some(updatedCallContext))
case e: Exception => Future(Failure("parsing failed: " + e.getMessage), Some(updatedCallContext))
}
case failure@Failure(_, _, _) =>
Future(failure, Some(callContext))
Future(failure, Some(updatedCallContext))
case _ =>
Future(Failure("Cannot extract data from: " + consentId), Some(callContext))
Future(Failure("Cannot extract data from: " + consentId), Some(updatedCallContext))
}
} else {
Future(Failure(ErrorMessages.TooManyRequests + s" ${RequestHeader.`Consent-ID`}: $consentId"), Some(callContext))
Future(Failure(ErrorMessages.TooManyRequests + s" ${RequestHeader.`Consent-ID`}: $consentId"), Some(updatedCallContext))
}
case failure@Failure(_, _, _) =>
Future(failure, Some(callContext))
case _ =>
Future(Failure(ErrorMessages.ConsentNotFound + s" ($consentId)"), Some(callContext))
}
}
private def hasBerlinGroupConsent(consentId: String, callContext: CallContext): Future[(Box[User], Option[CallContext])] = {
hasBerlinGroupConsentInternal(consentId, callContext)
}
def applyBerlinGroupRules(consentId: Option[String], callContext: CallContext): Future[(Box[User], Option[CallContext])] = {
val allowed = APIUtil.getPropsAsBoolValue(nameOfProperty="consents.allowed", defaultValue=false)
(consentId, allowed) match {
case (Some(consentId), true) => hasBerlinGroupConsent(consentId, callContext)
case (Some(consentId), true) => applyBerlinGroupConsentRulesCommon(consentId, callContext)
case (_, false) => Future((Failure(ErrorMessages.ConsentDisabled), Some(callContext)))
case (None, _) => Future((Failure(ErrorMessages.ConsentHeaderNotFound), Some(callContext)))
}
}
def applyRulesOldStyle(consentId: Option[String], callContext: CallContext): (Box[User], CallContext) = {
val allowed = APIUtil.getPropsAsBoolValue(nameOfProperty="consents.allowed", defaultValue=false)
(consentId, allowed) match {
case (Some(consentId), true) => hasConsentOldStyle(consentId, callContext)
case (Some(consentId), true) => (applyConsentRulesCommonOldStyle(consentId, callContext), callContext)
case (_, false) => (Failure(ErrorMessages.ConsentDisabled), callContext)
case (None, _) => (Failure(ErrorMessages.ConsentHeaderNotFound), callContext)
}
Expand Down
4 changes: 2 additions & 2 deletions obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ trait APIMethods300 {
availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u)
(coreAccounts, callContext) <- getFilteredCoreAccounts(availablePrivateAccounts, req, callContext)
} yield {
(JSONFactory300.createCoreAccountsByCoreAccountsJSON(coreAccounts), HttpCode.`200`(callContext))
(JSONFactory300.createCoreAccountsByCoreAccountsJSON(coreAccounts, u), HttpCode.`200`(callContext))
}
}
}
Expand Down Expand Up @@ -1710,7 +1710,7 @@ trait APIMethods300 {
availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u, bankId)
(accounts, callContext) <- getFilteredCoreAccounts(availablePrivateAccounts, req, callContext)
} yield {
(JSONFactory300.createCoreAccountsByCoreAccountsJSON(accounts), HttpCode.`200`(callContext))
(JSONFactory300.createCoreAccountsByCoreAccountsJSON(accounts, u), HttpCode.`200`(callContext))
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions obp-api/src/main/scala/code/api/v3_0_0/JSONFactory3.0.0.scala
Original file line number Diff line number Diff line change
Expand Up @@ -846,15 +846,15 @@ object JSONFactory300{
)
)

def createCoreAccountsByCoreAccountsJSON(coreAccounts : List[CoreAccount]): CoreAccountsJsonV300 =
def createCoreAccountsByCoreAccountsJSON(coreAccounts : List[CoreAccount], user: User): CoreAccountsJsonV300 =
CoreAccountsJsonV300(coreAccounts.map(coreAccount => CoreAccountJson(
coreAccount.id,
coreAccount.label,
coreAccount.bankId,
coreAccount.accountType,
coreAccount.accountRoutings.map(accountRounting =>AccountRoutingJsonV121(accountRounting.scheme, accountRounting.address)),
views = Views.views.vend
.assignedViewsForAccount(BankIdAccountId(BankId(coreAccount.bankId), AccountId(coreAccount.id))).filter(_.isPrivate)
.privateViewsUserCanAccessForAccount(user, BankIdAccountId(BankId(coreAccount.bankId), AccountId(coreAccount.id))).filter(_.isPrivate)
.map(mappedView =>
ViewBasicV300(
mappedView.viewId.value,
Expand Down
11 changes: 10 additions & 1 deletion obp-api/src/main/scala/code/api/v5_0_0/APIMethods500.scala
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import java.util.concurrent.ThreadLocalRandom
import code.accountattribute.AccountAttributeX
import code.api.Constant.SYSTEM_OWNER_VIEW_ID
import code.api.util.FutureUtil.EndpointContext
import code.consumer.Consumers
import code.util.Helper.booleanToFuture
import code.views.system.{AccountAccess, ViewDefinition}

Expand Down Expand Up @@ -991,7 +992,15 @@ trait APIMethods500 {
case Props.RunModes.Test => Consent.challengeAnswerAtTestEnvironment
case _ => SecureRandomUtil.numeric()
}
createdConsent <- Future(Consents.consentProvider.vend.createObpConsent(user, challengeAnswer, Some(consentRequestId))) map {
consumer = Consumers.consumers.vend.getConsumerByConsumerId(calculatedConsumerId.getOrElse("None"))
createdConsent <- Future(
Consents.consentProvider.vend.createObpConsent(
user,
challengeAnswer,
Some(consentRequestId),
consumer
)
) map {
i => connectorEmptyResponse(i, callContext)
}

Expand Down
33 changes: 32 additions & 1 deletion obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import code.api.v3_0_0.JSONFactory300
import code.api.v3_0_0.JSONFactory300.createAggregateMetricJson
import code.api.v3_1_0.ConsentJsonV310
import code.api.v3_1_0.JSONFactory310.createBadLoginStatusJson
import code.api.v4_0_0.JSONFactory400.{createAccountBalancesJson, createBalancesJson}
import code.api.v4_0_0.JSONFactory400.{createAccountBalancesJson, createBalancesJson, createNewCoreBankAccountJson}
import code.api.v4_0_0.{JSONFactory400, PostAccountAccessJsonV400, PostApiCollectionJson400, RevokedJsonV400}
import code.api.v5_1_0.JSONFactory510.{createRegulatedEntitiesJson, createRegulatedEntityJson}
import code.atmattribute.AtmAttribute
Expand Down Expand Up @@ -2155,6 +2155,37 @@ trait APIMethods510 {
}
}



staticResourceDocs += ResourceDoc(
getCoreAccountByIdThroughView,
implementedInApiVersion,
nameOf(getCoreAccountByIdThroughView),
"GET",
"/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID",
"Get Account by Id (Core) through the VIEW_ID",
s"""Information returned about the account through VIEW_ID :
|""".stripMargin,
EmptyBody,
moderatedCoreAccountJsonV400,
List($UserNotLoggedIn, $BankAccountNotFound,UnknownError),
apiTagAccount :: apiTagPSD2AIS :: apiTagPsd2 :: Nil
)
lazy val getCoreAccountByIdThroughView : OBPEndpoint = {
case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "views" :: ViewId(viewId) :: Nil JsonGet req => {
cc => implicit val ec = EndpointContext(Some(cc))
for {
(user @Full(u), account, callContext) <- SS.userAccount
bankIdAccountId = BankIdAccountId(account.bankId, account.accountId)
view <- NewStyle.function.checkViewAccessAndReturnView(viewId , bankIdAccountId, user, callContext)
moderatedAccount <- NewStyle.function.moderatedBankAccountCore(account, view, user, callContext)
} yield {
val availableViews: List[View] = Views.views.vend.privateViewsUserCanAccessForAccount(u, BankIdAccountId(account.bankId, account.accountId))
(createNewCoreBankAccountJson(moderatedAccount, availableViews), HttpCode.`200`(callContext))
}
}
}

staticResourceDocs += ResourceDoc(
getBankAccountBalances,
implementedInApiVersion,
Expand Down
2 changes: 1 addition & 1 deletion obp-api/src/main/scala/code/consent/ConsentProvider.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ trait ConsentProvider {
def updateConsentStatus(consentId: String, status: ConsentStatus): Box[MappedConsent]
def updateConsentUser(consentId: String, user: User): Box[MappedConsent]
def getConsentsByUser(userId: String): List[MappedConsent]
def createObpConsent(user: User, challengeAnswer: String, consentRequestId:Option[String]): Box[MappedConsent]
def createObpConsent(user: User, challengeAnswer: String, consentRequestId:Option[String], consumer: Option[Consumer] = None): Box[MappedConsent]
def setJsonWebToken(consentId: String, jwt: String): Box[MappedConsent]
def revoke(consentId: String): Box[MappedConsent]
def checkAnswer(consentId: String, challenge: String): Box[MappedConsent]
Expand Down
3 changes: 2 additions & 1 deletion obp-api/src/main/scala/code/consent/MappedConsent.scala
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,14 @@ object MappedConsentProvider extends ConsentProvider {
override def getConsentsByUser(userId: String): List[MappedConsent] = {
MappedConsent.findAll(By(MappedConsent.mUserId, userId))
}
override def createObpConsent(user: User, challengeAnswer: String, consentRequestId:Option[String]): Box[MappedConsent] = {
override def createObpConsent(user: User, challengeAnswer: String, consentRequestId:Option[String], consumer: Option[Consumer]): Box[MappedConsent] = {
tryo {
val salt = BCrypt.gensalt()
val challengeAnswerHashed = BCrypt.hashpw(challengeAnswer, salt).substring(0, 44)
MappedConsent
.create
.mUserId(user.userId)
.mConsumerId(consumer.map(_.consumerId.get).getOrElse(null))
.mConsentRequestId(consentRequestId.getOrElse(null))
.mChallenge(challengeAnswerHashed)
.mSalt(salt)
Expand Down
Binary file removed obp-api/src/test/resources/cert/client.pfx
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading

0 comments on commit f1303bf

Please sign in to comment.