feature/AAdd Endpoint Create Consent Implicit without BANK_ID

This commit is contained in:
Marko Milić 2025-01-22 11:09:44 +01:00
parent 62f1193bef
commit 858e67aa28
3 changed files with 470 additions and 4 deletions

View File

@ -21,7 +21,7 @@ import code.api.v2_0_0.AccountsHelper.{accountTypeFilterText, getFilteredCoreAcc
import code.api.v2_1_0.{ConsumerRedirectUrlJSON, JSONFactory210}
import code.api.v3_0_0.JSONFactory300
import code.api.v3_0_0.JSONFactory300.createAggregateMetricJson
import code.api.v3_1_0.{ConsentChallengeJsonV310, ConsentJsonV310}
import code.api.v3_1_0.{ConsentChallengeJsonV310, ConsentJsonV310, PostConsentBodyCommonJson, PostConsentEmailJsonV310, PostConsentPhoneJsonV310}
import code.api.v3_1_0.JSONFactory310.{createBadLoginStatusJson, createConsumerJSON, createRefreshUserJson}
import code.api.v4_0_0.JSONFactory400.{createAccountBalancesJson, createBalancesJson, createNewCoreBankAccountJson}
import code.api.v4_0_0.{JSONFactory400, PostAccountAccessJsonV400, PostApiCollectionJson400, PutConsentStatusJsonV400, PutConsentUserJsonV400, RevokedJsonV400}
@ -31,6 +31,7 @@ import code.atmattribute.AtmAttribute
import code.bankconnectors.Connector
import code.consent.{ConsentRequests, ConsentStatus, Consents, MappedConsent}
import code.consumer.Consumers
import code.entitlement.Entitlement
import code.loginattempts.LoginAttempt
import code.metrics.APIMetrics
import code.metrics.MappedMetric.userId
@ -46,7 +47,7 @@ import code.views.system.{AccountAccess, ViewDefinition}
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.ExecutionContext.Implicits.global
import com.openbankproject.commons.model._
import com.openbankproject.commons.model.enums.{AtmAttributeType, ConsentType, TransactionRequestStatus, UserAttributeType}
import com.openbankproject.commons.model.enums.{AtmAttributeType, ConsentType, StrongCustomerAuthentication, TransactionRequestStatus, UserAttributeType}
import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion}
import net.liftweb.common.Full
import net.liftweb.http.rest.RestHelper
@ -54,7 +55,7 @@ import net.liftweb.json
import net.liftweb.json.{Extraction, compactRender, parse, prettyRender}
import net.liftweb.mapper.By
import net.liftweb.util.Helpers.tryo
import net.liftweb.util.{Helpers, StringHelpers}
import net.liftweb.util.{Helpers, Props, StringHelpers}
import java.text.SimpleDateFormat
import java.time.{LocalDate, ZoneId}
@ -1843,7 +1844,287 @@ trait APIMethods510 {
}
}
}
val generalObpConsentText: String =
s"""
|
|An OBP Consent allows the holder of the Consent to call one or more endpoints.
|
|Consents must be created and authorisied using SCA (Strong Customer Authentication).
|
|That is, Consents can be created by an authorised User via the OBP REST API but they must be confirmed via an out of band (OOB) mechanism such as a code sent to a mobile phone.
|
|Each Consent has one of the following states: ${ConsentStatus.values.toList.sorted.mkString(", ")}.
|
|Each Consent is bound to a consumer i.e. you need to identify yourself over request header value Consumer-Key.
|For example:
|GET /obp/v4.0.0/users/current HTTP/1.1
|Host: 127.0.0.1:8080
|Consent-JWT: eyJhbGciOiJIUzI1NiJ9.eyJlbnRpdGxlbWVudHMiOlt7InJvbGVfbmFtZSI6IkNhbkdldEFueVVzZXIiLCJiYW5rX2lkIjoiIn
|1dLCJjcmVhdGVkQnlVc2VySWQiOiJhYjY1MzlhOS1iMTA1LTQ0ODktYTg4My0wYWQ4ZDZjNjE2NTciLCJzdWIiOiIzNDc1MDEzZi03YmY5LTQyNj
|EtOWUxYy0xZTdlNWZjZTJlN2UiLCJhdWQiOiI4MTVhMGVmMS00YjZhLTQyMDUtYjExMi1lNDVmZDZmNGQzYWQiLCJuYmYiOjE1ODA3NDE2NjcsIml
|zcyI6Imh0dHA6XC9cLzEyNy4wLjAuMTo4MDgwIiwiZXhwIjoxNTgwNzQ1MjY3LCJpYXQiOjE1ODA3NDE2NjcsImp0aSI6ImJkYzVjZTk5LTE2ZTY
|tNDM4Yi1hNjllLTU3MTAzN2RhMTg3OCIsInZpZXdzIjpbXX0.L3fEEEhdCVr3qnmyRKBBUaIQ7dk1VjiFaEBW8hUNjfg
|
|Consumer-Key: ejznk505d132ryomnhbx1qmtohurbsbb0kijajsk
|cache-control: no-cache
|
|Maximum time to live of the token is specified over props value consents.max_time_to_live. In case isn't defined default value is 3600 seconds.
|
|Example of POST JSON:
|{
| "everything": false,
| "views": [
| {
| "bank_id": "GENODEM1GLS",
| "account_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
| "view_id": "${Constant.SYSTEM_OWNER_VIEW_ID}"
| }
| ],
| "entitlements": [
| {
| "bank_id": "GENODEM1GLS",
| "role_name": "CanGetCustomer"
| }
| ],
| "consumer_id": "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh",
| "email": "eveline@example.com",
| "valid_from": "2020-02-07T08:43:34Z",
| "time_to_live": 3600
|}
|Please note that only optional fields are: consumer_id, valid_from and time_to_live.
|In case you omit they the default values are used:
|consumer_id = consumer of current user
|valid_from = current time
|time_to_live = consents.max_time_to_live
|
""".stripMargin
staticResourceDocs += ResourceDoc(
createConsentImplicit,
implementedInApiVersion,
nameOf(createConsentImplicit),
"POST",
"/my/consents/IMPLICIT",
"Create Consent (IMPLICIT)",
s"""
|
|This endpoint starts the process of creating a Consent.
|
|The Consent is created in an ${ConsentStatus.INITIATED} state.
|
|A One Time Password (OTP) (AKA security challenge) is sent Out of Band (OOB) to the User via the transport defined in SCA_METHOD
|SCA_METHOD is typically "SMS","EMAIL" or "IMPLICIT". "EMAIL" is used for testing purposes. OBP mapped mode "IMPLICIT" is "EMAIL".
|Other mode, bank can decide it in the connector method 'getConsentImplicitSCA'.
|
|When the Consent is created, OBP (or a backend system) stores the challenge so it can be checked later against the value supplied by the User with the Answer Consent Challenge endpoint.
|
|$generalObpConsentText
|
|${userAuthenticationMessage(true)}
|
|Example 1:
|{
| "everything": true,
| "views": [],
| "entitlements": [],
| "consumer_id": "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh",
|}
|
|Please note that consumer_id is optional field
|Example 2:
|{
| "everything": true,
| "views": [],
| "entitlements": [],
|}
|
|Please note if everything=false you need to explicitly specify views and entitlements
|Example 3:
|{
| "everything": false,
| "views": [
| {
| "bank_id": "GENODEM1GLS",
| "account_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
| "view_id": "${Constant.SYSTEM_OWNER_VIEW_ID}"
| }
| ],
| "entitlements": [
| {
| "bank_id": "GENODEM1GLS",
| "role_name": "CanGetCustomer"
| }
| ],
| "consumer_id": "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh",
|}
|
|""",
postConsentImplicitJsonV310,
consentJsonV310,
List(
UserNotLoggedIn,
BankNotFound,
InvalidJsonFormat,
ConsentAllowedScaMethods,
RolesAllowedInConsent,
ViewsAllowedInConsent,
ConsumerNotFoundByConsumerId,
ConsumerIsDisabled,
MissingPropsValueAtThisInstance,
SmsServerNotResponding,
InvalidConnectorResponse,
UnknownError
),
apiTagConsent :: apiTagPSD2AIS :: apiTagPsd2 :: Nil)
lazy val createConsentImplicit = createConsent
lazy val createConsent: OBPEndpoint = {
case "my" :: "consents" :: scaMethod :: Nil JsonPost json -> _ => {
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
(Full(user), callContext) <- authenticatedAccess(cc)
_ <- Helper.booleanToFuture(ConsentAllowedScaMethods, cc = callContext) {
List(StrongCustomerAuthentication.SMS.toString(), StrongCustomerAuthentication.EMAIL.toString(), StrongCustomerAuthentication.IMPLICIT.toString()).exists(_ == scaMethod)
}
failMsg = s"$InvalidJsonFormat The Json body should be the $PostConsentBodyCommonJson "
consentJson <- NewStyle.function.tryons(failMsg, 400, callContext) {
json.extract[PostConsentBodyCommonJson]
}
maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600)
_ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContext) {
consentJson.time_to_live match {
case Some(ttl) => ttl <= maxTimeToLive
case _ => true
}
}
requestedEntitlements = consentJson.entitlements
myEntitlements <- Entitlement.entitlement.vend.getEntitlementsByUserIdFuture(user.userId)
_ <- Helper.booleanToFuture(RolesAllowedInConsent, cc = callContext) {
requestedEntitlements.forall(
re => myEntitlements.getOrElse(Nil).exists(
e => e.roleName == re.role_name && e.bankId == re.bank_id
)
)
}
requestedViews = consentJson.views
(_, assignedViews) <- Future(Views.views.vend.privateViewsUserCanAccess(user))
_ <- Helper.booleanToFuture(ViewsAllowedInConsent, cc = callContext) {
requestedViews.forall(
rv => assignedViews.exists {
e =>
e.view_id == rv.view_id &&
e.bank_id == rv.bank_id &&
e.account_id == rv.account_id
}
)
}
(consumerId, applicationText) <- consentJson.consumer_id match {
case Some(id) => NewStyle.function.checkConsumerByConsumerId(id, callContext) map {
c => (Some(c.consumerId.get), c.description)
}
case None => Future(None, "Any application")
}
challengeAnswer = Props.mode match {
case Props.RunModes.Test => Consent.challengeAnswerAtTestEnvironment
case _ => SecureRandomUtil.numeric()
}
createdConsent <- Future(Consents.consentProvider.vend.createObpConsent(user, challengeAnswer, None)) map {
i => connectorEmptyResponse(i, callContext)
}
consentJWT =
Consent.createConsentJWT(
user,
consentJson,
createdConsent.secret,
createdConsent.consentId,
consumerId,
consentJson.valid_from,
consentJson.time_to_live.getOrElse(3600)
)
_ <- Future(Consents.consentProvider.vend.setJsonWebToken(createdConsent.consentId, consentJWT)) map {
i => connectorEmptyResponse(i, callContext)
}
challengeText = s"Your consent challenge : ${challengeAnswer}, Application: $applicationText"
_ <- scaMethod match {
case v if v == StrongCustomerAuthentication.EMAIL.toString => // Send the email
for {
failMsg <- Future {
s"$InvalidJsonFormat The Json body should be the $PostConsentEmailJsonV310"
}
postConsentEmailJson <- NewStyle.function.tryons(failMsg, 400, callContext) {
json.extract[PostConsentEmailJsonV310]
}
(status, callContext) <- NewStyle.function.sendCustomerNotification(
StrongCustomerAuthentication.EMAIL,
postConsentEmailJson.email,
Some("OBP Consent Challenge"),
challengeText,
callContext
)
} yield Future {
status
}
case v if v == StrongCustomerAuthentication.SMS.toString =>
for {
failMsg <- Future {
s"$InvalidJsonFormat The Json body should be the $PostConsentPhoneJsonV310"
}
postConsentPhoneJson <- NewStyle.function.tryons(failMsg, 400, callContext) {
json.extract[PostConsentPhoneJsonV310]
}
phoneNumber = postConsentPhoneJson.phone_number
(status, callContext) <- NewStyle.function.sendCustomerNotification(
StrongCustomerAuthentication.SMS,
phoneNumber,
None,
challengeText,
callContext
)
} yield Future {
status
}
case v if v == StrongCustomerAuthentication.IMPLICIT.toString =>
for {
(consentImplicitSCA, callContext) <- NewStyle.function.getConsentImplicitSCA(user, callContext)
status <- consentImplicitSCA.scaMethod match {
case v if v == StrongCustomerAuthentication.EMAIL => // Send the email
NewStyle.function.sendCustomerNotification(
StrongCustomerAuthentication.EMAIL,
consentImplicitSCA.recipient,
Some("OBP Consent Challenge"),
challengeText,
callContext
)
case v if v == StrongCustomerAuthentication.SMS =>
NewStyle.function.sendCustomerNotification(
StrongCustomerAuthentication.SMS,
consentImplicitSCA.recipient,
None,
challengeText,
callContext
)
case _ => Future {
"Success"
}
}} yield {
status
}
case _ => Future {
"Success"
}
}
} yield {
(ConsentJsonV310(createdConsent.consentId, consentJWT, createdConsent.status), HttpCode.`201`(callContext))
}
}
}
staticResourceDocs += ResourceDoc(
mtlsClientCertificateInfo,

View File

@ -0,0 +1,179 @@
/**
Open Bank Project - API
Copyright (C) 2011-2019, TESOBE GmbH
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Email: contact@tesobe.com
TESOBE GmbH
Osloerstrasse 16/17
Berlin 13359, Germany
This product includes software developed at
TESOBE (http://www.tesobe.com/)
*/
package code.api.v5_1_0
import code.api.{Constant, RequestHeader}
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON
import code.api.util.ApiRole._
import code.api.util.ErrorMessages._
import code.api.util.{APIUtil, Consent}
import code.api.util.APIUtil.OAuth._
import code.api.v3_0_0.{APIMethods300, UserJsonV300}
import code.api.v3_1_0.{ConsentJsonV310, PostConsentChallengeJsonV310, PostConsentEntitlementJsonV310, PostConsentViewJsonV310}
import code.api.v3_1_0.OBPAPI3_1_0.Implementations3_1_0
import code.api.v5_1_0.OBPAPI5_1_0.Implementations5_1_0
import code.entitlement.Entitlement
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.model.ErrorMessage
import com.openbankproject.commons.util.ApiVersion
import net.liftweb.json.Serialization.write
import org.scalatest.Tag
class ConsentObpTest extends V510ServerSetup {
/**
* Test tags
* Example: To run tests with tag "getPermissions":
* mvn test -D tagsToInclude
*
* This is made possible by the scalatest maven plugin
*/
object VersionOfApi extends Tag(ApiVersion.v5_1_0.toString)
object CreateConsent extends Tag(nameOf(Implementations5_1_0.createConsent))
object AnswerConsentChallenge extends Tag(nameOf(Implementations3_1_0.answerConsentChallenge))
object VersionOfApi2 extends Tag(ApiVersion.v3_0_0.toString)
object GetUserByUserId extends Tag(nameOf(APIMethods300.Implementations3_0_0.getUserByUserId))
lazy val bankId = randomBankId
lazy val bankAccount = randomPrivateAccount(bankId)
lazy val entitlements = List(PostConsentEntitlementJsonV310("", CanGetAnyUser.toString()))
lazy val views = List(PostConsentViewJsonV310(bankId, bankAccount.id, Constant.SYSTEM_OWNER_VIEW_ID))
lazy val postConsentEmailJsonV310 = SwaggerDefinitionsJSON.postConsentEmailJsonV310
.copy(entitlements=entitlements)
.copy(consumer_id=Some(testConsumer.consumerId.get))
.copy(views=views)
lazy val postConsentImplicitJsonV310 = SwaggerDefinitionsJSON.postConsentImplicitJsonV310
.copy(entitlements=entitlements)
.copy(consumer_id=Some(testConsumer.consumerId.get))
.copy(views=views)
val maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty="consents.max_time_to_live", defaultValue=3600)
val timeToLive: Option[Long] = Some(maxTimeToLive + 10)
feature(s"test $CreateConsent version $VersionOfApi - Unauthorized access")
{
scenario("We will call the endpoint without user credentials-IMPLICIT", CreateConsent, VersionOfApi) {
When("We make a request")
val request = (v5_1_0_Request / "my" / "consents" / "IMPLICIT" ).POST
val response = makePostRequest(request, write(postConsentImplicitJsonV310))
Then("We should get a 401")
response.code should equal(401)
response.body.extract[ErrorMessage].message should equal(UserNotLoggedIn)
}
scenario("We will call the endpoint with user credentials-Implicit", CreateConsent, GetUserByUserId, VersionOfApi, VersionOfApi2) {
setPropsValues("consumer_validation_method_for_consent"-> "CONSUMER_KEY_VALUE")
wholeFunctionalityImplicit(RequestHeader.`Consent-JWT`)
setPropsValues("consumer_validation_method_for_consent"-> "CONSUMER_CERTIFICATE")
}
scenario("We will call the endpoint with user credentials and deprecated header name-Implicit", CreateConsent, GetUserByUserId, VersionOfApi, VersionOfApi2) {
setPropsValues("consumer_validation_method_for_consent"-> "CONSUMER_KEY_VALUE")
wholeFunctionalityImplicit(RequestHeader.`Consent-Id`)
setPropsValues("consumer_validation_method_for_consent"-> "CONSUMER_CERTIFICATE")
}
}
private def wholeFunctionalityImplicit(nameOfRequestHeader: String) = {
When("We make a request")
// Create a consent as the user1.
// Must fail because we try to set time_to_live=4500
val requestWrongTimeToLive = (v5_1_0_Request / "my" / "consents" / "IMPLICIT").POST <@ (user1)
val responseWrongTimeToLive = makePostRequest(requestWrongTimeToLive, write(postConsentImplicitJsonV310.copy(time_to_live = timeToLive)))
Then("We should get a 400")
responseWrongTimeToLive.code should equal(400)
responseWrongTimeToLive.body.extract[ErrorMessage].message should include(ConsentMaxTTL)
// Create a consent as the user1.
// Must fail because we try to assign a role other that user already have access to the request
val request = (v5_1_0_Request / "my" / "consents" / "IMPLICIT").POST <@ (user1)
val response = makePostRequest(request, write(postConsentImplicitJsonV310))
Then("We should get a 400")
response.code should equal(400)
response.body.extract[ErrorMessage].message should equal(RolesAllowedInConsent)
Then("We grant the role and test it again")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString)
// Create a consent as the user1. The consent is in status INITIATED
val secondResponse = makePostRequest(request, write(postConsentImplicitJsonV310))
Then("We should get a 201")
secondResponse.code should equal(201)
val consentId = secondResponse.body.extract[ConsentJsonV310].consent_id
val jwt = secondResponse.body.extract[ConsentJsonV310].jwt
val header = List((nameOfRequestHeader, jwt))
// Make a request with the consent which is NOT in status ACCEPTED
val requestGetUserByUserId = (v5_1_0_Request / "users" / "current").GET
val responseGetUserByUserId = makeGetRequest(requestGetUserByUserId, header)
APIUtil.getPropsAsBoolValue(nameOfProperty = "consents.allowed", defaultValue = false) match {
case true =>
// Due to the wrong status of the consent the request must fail
responseGetUserByUserId.body.extract[ErrorMessage].message should include(ConsentStatusIssue)
// Answer security challenge i.e. SCA
val answerConsentChallengeRequest = (v5_1_0_Request / "banks" / bankId / "consents" / consentId / "challenge").POST <@ (user1)
val challenge = Consent.challengeAnswerAtTestEnvironment
val post = PostConsentChallengeJsonV310(answer = challenge)
val response = makePostRequest(answerConsentChallengeRequest, write(post))
Then("We should get a 201")
response.code should equal(201)
// Make a request WITHOUT the request header "Consumer-Key: SOME_VALUE"
// Due to missing value the request must fail
makeGetRequest(requestGetUserByUserId, header)
.body.extract[ErrorMessage].message should include(ConsumerKeyHeaderMissing)
// Make a request WITH the request header "Consumer-Key: NON_EXISTING_VALUE"
// Due to non existing value the request must fail
val headerConsumerKey = List((RequestHeader.`Consumer-Key`, "NON_EXISTING_VALUE"))
makeGetRequest(requestGetUserByUserId, header ::: headerConsumerKey)
.body.extract[ErrorMessage].message should include(ConsentDoesNotMatchConsumer)
// Make a request WITH the request header "Consumer-Key: EXISTING_VALUE"
val validHeaderConsumerKey = List((RequestHeader.`Consumer-Key`, user1.map(_._1.key).getOrElse("SHOULD_NOT_HAPPEN")))
val response2 = makeGetRequest((v5_1_0_Request / "users" / "current").GET, header ::: validHeaderConsumerKey)
val user = response2.body.extract[UserJsonV300]
val assignedEntitlements: Seq[PostConsentEntitlementJsonV310] = user.entitlements.list.flatMap(
e => entitlements.find(_ == PostConsentEntitlementJsonV310(e.bank_id, e.role_name))
)
// Check we have all entitlements from the consent
assignedEntitlements should equal(entitlements)
// Every consent implies a brand new user is created
user.user_id should not equal (resourceUser1.userId)
// Check we have all views from the consent
val assignedViews = user.views.map(_.list).toSeq.flatten
assignedViews.map(e => PostConsentViewJsonV310(e.bank_id, e.account_id, e.view_id)).distinct should equal(views)
case false =>
// Due to missing props at the instance the request must fail
responseGetUserByUserId.body.extract[ErrorMessage].message should include(ConsentDisabled)
}
}
}

View File

@ -43,6 +43,12 @@ trait V510ServerSetup extends ServerSetupWithTestData with DefaultUsers {
bank.id
}
def randomPrivateAccount(bankId: String): AccountJSON = {
val accountsJson = getPrivateAccounts(bankId, user1).body.extract[AccountsJSON].accounts
val randomPosition = nextInt(accountsJson.size)
accountsJson(randomPosition)
}
def getPrivateAccountsViaEndpoint(bankId : String, consumerAndToken: Option[(Consumer, Token)]) : APIResponse = {
val request = v5_1_0_Request / "banks" / bankId / "accounts" / "private" <@(consumerAndToken)
makeGetRequest(request)