diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index e626a22c8..6e33002ef 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -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, diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala new file mode 100644 index 000000000..f32313162 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala @@ -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 . + +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) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v5_1_0/V510ServerSetup.scala b/obp-api/src/test/scala/code/api/v5_1_0/V510ServerSetup.scala index fb612b1a5..e25fd43b2 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/V510ServerSetup.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/V510ServerSetup.scala @@ -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)