diff --git a/obp-api/src/main/resources/docs/introductory_system_documentation.md b/obp-api/src/main/resources/docs/introductory_system_documentation.md index e86d38b42e..1e60c2f2ba 100644 --- a/obp-api/src/main/resources/docs/introductory_system_documentation.md +++ b/obp-api/src/main/resources/docs/introductory_system_documentation.md @@ -3470,8 +3470,8 @@ GET /obp/v5.1.0/management/logs/INFO **Testing Endpoints:** ```bash -# Test delay/timeout handling -GET /obp/v5.1.0/development/waiting-for-godot?sleep=1000 +# Test delay/timeout handling (needs the Role CanGetWaitingForGodot; the delay is capped per instance) +GET /obp/v5.1.0/waiting-for-godot?sleep=1000 # Test rate limiting GET /obp/v5.1.0/rate-limiting diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 511cf059db..8be6c442ad 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -971,9 +971,22 @@ super_admin_user_ids=USER_ID1,USER_ID2, # Configure Bearer-JWT validation via the oauth2.* props further below # (oauth2.jwk_set.url, oauth2.keycloak.*, oauth2.obp_oidc.*). +# The longest delay GET /obp/v5.1.0/waiting-for-godot?sleep=N will make, in milliseconds. +# A larger requested value is reduced to this. (The endpoint also needs the Role CanGetWaitingForGodot.) +#waiting_for_godot_max_sleep_in_milliseconds = 5000 + # When new consumers inserted they should use this setting. consumers_enabled_by_default=true +# Optional. Limits which hosts a Consumer redirect URL may point to. +# Empty (the default) puts no limit on hosts: any host that passes the redirect URL rules +# (https, http only for localhost, reverse-domain app schemes) is accepted. +# When set: a comma-separated list of exact hosts (portal.example.com) or domains (.example.com, +# which allows example.com and any host under it). The API's own host (hostname) is always included. +# Checked when a Consumer redirect URL is created or updated. App schemes, Berlin Group dynamic +# registration and TPP-Redirect-URI headers, and redirect URLs already stored are not affected. +#redirect_url_allowed_hosts = + # Autocomplete for login form has to be explicitly set autocomplete_at_login_form_enabled=false @@ -1682,6 +1695,10 @@ default_auth_context_update_request_key=CUSTOMER_NUMBER #berlin_group_mandatory_headers = Content-Type,Date,Digest,PSU-Device-ID,PSU-Device-Name,PSU-IP-Address,Signature,TPP-Signature-Certificate,X-Request-ID #berlin_group_mandatory_header_consent = TPP-Redirect-URI +# Require the TPP signature to cover TPP-Redirect-URI and TPP-Nok-Redirect-URI whenever they are sent. +# When true, the Signature header's headers list must include each of them that the request carries. +#berlin_group_require_signed_tpp_redirect_uri = false + ## Berlin Group Create Consent Frequency per Day Upper Limit #berlin_group_frequency_per_day_upper_limit = 4 diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 162debf7bb..1398389be6 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -525,6 +525,7 @@ class Boot extends MdcLoggable { logger.info("running mode: " + runningMode) logger.info(s"ApiPathZero (the bit before version) is $ApiPathZero") + logger.info(s"Boot says: redirect URL host allow list (redirect_url_allowed_hosts): ${code.api.util.RedirectUrlValidation.allowedHostsDescription}") logger.debug(s"If you can read this, logging level is debug") diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index a47bb6fabe..874a0ca74c 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -2959,7 +2959,7 @@ object SwaggerDefinitionsJSON { app_type = "Web", description = "Account Management", developer_email = ExampleValue.emailExample.value, - redirect_url = "www.openbankproject.com", + redirect_url = redirectUrlExample.value, created_by_user_id = ExampleValue.userIdExample.value, created_by_user = resourceUserJSON, enabled = true, @@ -3051,7 +3051,7 @@ object SwaggerDefinitionsJSON { app_type = "Web", description = "Account Management", developer_email = ExampleValue.emailExample.value, - redirect_url = "www.openbankproject.com", + redirect_url = redirectUrlExample.value, created_by_user = resourceUserJSON, enabled = true, created = DateWithDayExampleObject @@ -3068,7 +3068,7 @@ object SwaggerDefinitionsJSON { |client_certificate_content |-----END CERTIFICATE-----""".stripMargin, developer_email = ExampleValue.emailExample.value, - redirect_url = "www.openbankproject.com", + redirect_url = redirectUrlExample.value, created_by_user_id = ExampleValue.userIdExample.value, created_by_user = resourceUserJSON, enabled = true, diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 3e61f5037b..9b797377d9 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -121,6 +121,10 @@ object ApiRole extends MdcLoggable{ case class CanGetCustomersAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canGetCustomersAtOneBank = CanGetCustomersAtOneBank() + // Reading every CRM event (customer name, number and the staff notes) at one bank. + case class CanGetCrmEvents(requiresBankId: Boolean = true) extends ApiRole + lazy val canGetCrmEvents = CanGetCrmEvents() + case class CanGetCustomersMinimalAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canGetCustomersMinimalAtOneBank = CanGetCustomersMinimalAtOneBank() @@ -153,6 +157,10 @@ object ApiRole extends MdcLoggable{ case class CanGetSystemLogCacheAll(requiresBankId: Boolean = false) extends ApiRole lazy val canGetSystemLogCacheAll = CanGetSystemLogCacheAll() + // Calling waitingForGodot, a diagnostic endpoint that delays its response on request. + case class CanGetWaitingForGodot(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetWaitingForGodot = CanGetWaitingForGodot() + case class CanUpdateAgentStatusAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canUpdateAgentStatusAtAnyBank = CanUpdateAgentStatusAtAnyBank() diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala index 49ec6cf54f..1129167ae6 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -56,6 +56,13 @@ object BerlinGroupCheck extends MdcLoggable { .map(_.trim.toLowerCase) .toList.filterNot(_.isEmpty) + // When true, a request carrying TPP-Redirect-URI or TPP-Nok-Redirect-URI must list that header in its + // Signature header, so the redirect target cannot be swapped without breaking the TPP's signature. + private def requireSignedTppRedirectUri: Boolean = + APIUtil.getPropsAsBoolValue("berlin_group_require_signed_tpp_redirect_uri", defaultValue = false) + + private val tppRedirectHeaders = List(RequestHeader.`TPP-Redirect-URI`, RequestHeader.`TPP-Nok-Redirect-URI`) + def hasUnwantedConsentIdHeaderForBGEndpoint(path: String, reqHeaders: List[HTTPParam]): Boolean = { val hasConsentIdId = RequestHeadersUtil.find(reqHeaders, RequestHeader.`Consent-ID`).flatMap(_.values.headOption).isDefined @@ -151,6 +158,44 @@ object BerlinGroupCheck extends MdcLoggable { } + // === TPP redirect URIs === + // The PSU's browser is sent to these after authorisation, so they must meet the same redirect URL + // rules as a Consumer's redirect URL: a javascript: or plain-http public target is refused here. + val resultWithInvalidTppRedirectUriCheck: Option[(Box[User], Option[CallContext])] = + tppRedirectHeaders.iterator.flatMap { headerName => + RequestHeadersUtil.find(reqHeaders, headerName).flatMap(_.values.headOption).flatMap { value => + RedirectUrlValidation.problemWith(value.trim).map(problem => s"${ErrorMessages.InvalidRedirectUrl}$headerName '$value': $problem") + } + }.toList.headOption.map { message => + (fullBoxOrException(Empty ~> APIFailureNewStyle(message, 400, forwardResult._2.map(_.toLight))), forwardResult._2) + } + + val resultWithUnsignedTppRedirectUriCheck: Option[(Box[User], Option[CallContext])] = + if (!requireSignedTppRedirectUri) None + else { + val signedHeaders: List[String] = RequestHeadersUtil.find(reqHeaders, RequestHeader.Signature) + .flatMap(_.values.headOption) + .flatMap(header => BerlinGroupSignatureHeaderParser.parseSignatureHeader(header).toOption) + .map(_.headers.map(_.toLowerCase)) + .getOrElse(Nil) + val unsignedRedirectHeaders = tppRedirectHeaders + .filter(RequestHeadersUtil.exists(reqHeaders, _)) + .filterNot(headerName => signedHeaders.contains(headerName.toLowerCase)) + if (unsignedRedirectHeaders.isEmpty) None + else Some( + ( + fullBoxOrException( + Empty ~> APIFailureNewStyle( + s"${ErrorMessages.InvalidSignatureHeader} The signature must cover ${unsignedRedirectHeaders.mkString(", ")}.", + 400, + forwardResult._2.map(_.toLight) + ) + ), + forwardResult._2 + ) + ) + } + // === Signature Header Parsing === val resultWithInvalidSignatureHeaderCheck: Option[(Box[User], Option[CallContext])] = { val maybeSignature: Option[String] = RequestHeadersUtil.find(reqHeaders, RequestHeader.Signature).flatMap(_.values.headOption) @@ -217,6 +262,8 @@ object BerlinGroupCheck extends MdcLoggable { .orElse(resultWithWrongDateHeaderCheck) .orElse(resultWithInvalidRequestIdCheck) .orElse(resultWithRequestIdUsedTwiceCheck) + .orElse(resultWithInvalidTppRedirectUriCheck) + .orElse(resultWithUnsignedTppRedirectUriCheck) .orElse(resultWithInvalidSignatureHeaderCheck) .getOrElse(forwardResult) } diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala index e049b5ff6a..62124461ce 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala @@ -426,7 +426,7 @@ object BerlinGroupSigning extends MdcLoggable { val dateHeader = getCurrentDate - val redirectUri = "www.redirect-uri.com" + val redirectUri = "https://tpp.example.com/redirect" val headers = Map( RequestHeader.Digest -> s"SHA-256=$digest", RequestHeader.`X-Request-ID` -> xRequestId, diff --git a/obp-api/src/main/scala/code/api/util/CommonFunctions.scala b/obp-api/src/main/scala/code/api/util/CommonFunctions.scala index 1370e3a034..c2d5e27a18 100644 --- a/obp-api/src/main/scala/code/api/util/CommonFunctions.scala +++ b/obp-api/src/main/scala/code/api/util/CommonFunctions.scala @@ -54,6 +54,14 @@ object CommonFunctions extends MdcLoggable { Nil } + /** + * This validation applies the redirect URL rules of RedirectUrlValidation to a Mapper field, so a Consumer + * cannot be saved through createConsumer with a redirect URL that breaks them. The instance's host list is an + * endpoint-level policy (dynamic registration is exempt), so it is not applied here. + */ + def validRedirectUrls[T <: MappedString[_]](field: T)(s: String): List[FieldError] = + RedirectUrlValidation.consumerRedirectUrlError(s, applyHostList = false).map(error => FieldError(field, error)).toList + private def validUrl[T <: MappedString[_]](field: T)(s: String): List[FieldError] = { import java.net.URL diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 56a05edd2d..a3f6aba03a 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -350,6 +350,8 @@ object ErrorMessages { val BankAccountBalanceNotFoundById = "OBP-20105: BankAccountBalance not found. Please specify a valid value for BALANCE_ID." val UserNotFoundByToken = "OBP-20106: User not found by token. The validation token is invalid or expired." val UserAlreadyValidated = "OBP-20107: User email is already validated." + val InvalidRedirectUrl = "OBP-20108: Invalid redirect URL. Each redirect URL must use https, use http only for localhost, 127.0.0.1 or [::1], or use an app scheme in reverse-domain form such as com.example.app, and must not contain a wildcard, user information or a fragment. Rejected entry: " + val RedirectUrlHostNotAllowed = "OBP-20109: Redirect URL host not allowed. This instance only accepts redirect URLs that point to the hosts listed for it. Rejected entry: " // OAuth 2 val ApplicationNotIdentified = "OBP-20200: The application cannot be identified. " @@ -507,6 +509,7 @@ object ErrorMessages { val CardNotFound = "OBP-30059: This Card can not be found for the user " val CardAlreadyExists = "OBP-30060: Card already exists. Please specify different values for bankId, card_number and issueNumber." val CardAttributeNotFound = "OBP-30061: Card Attribute not found. Please specify a valid value for CARD_ATTRIBUTE_ID." + val CounterpartyAttributeNotFound = "OBP-30121: Counterparty Attribute not found. Please specify a valid value for COUNTERPARTY_ATTRIBUTE_ID for this COUNTERPARTY_ID." val ParentProductNotFoundByProductCode = "OBP-30062: Parent product not found. Please specify an existing product code for parent_product_code. Leave empty if no parent product exists." val CannotGrantAccountAccess = "OBP-30063: Cannot grant account access." val CannotRevokeAccountAccess = "OBP-30064: Cannot revoke account access." @@ -514,6 +517,7 @@ object ErrorMessages { val CannotGetAccounts = "OBP-30066: Could not get accounts." val TransactionNotFound = "OBP-30067: Transaction not found. Please specify a valid value for TRANSACTION_ID." val RefundedTransaction = "OBP-30068: Transaction was already refunded . Please specify a valid value for TRANSACTION_ID." + val RefundTransactionNotWithThisParty = "OBP-30122: The transaction to refund was not made with this party. Please specify a TRANSACTION_ID of a payment between this account and the party named in the request." val CustomerAttributeNotFound = "OBP-30069: Customer Attribute not found. Please specify a valid value for CUSTOMER_ATTRIBUTE_ID." val TransactionAttributeNotFound = "OBP-30070: Transaction Attribute not found. Please specify a valid value for TRANSACTION_ATTRIBUTE_ID." val AttributeNotFound = "OBP-30071: Attribute Definition not found. Please specify a valid value for ATTRIBUTE_DEFINITION_ID." diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index f29998ffd1..cb330f0a3e 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -3283,6 +3283,36 @@ object Glossary extends MdcLoggable { """) + glossaryItems += GlossaryItem( + title = "Redirect URL", + description = + s""" +|A Consumer's redirect URL is where an OIDC provider (such as OBP-OIDC) sends the User's browser, with an authorisation code, after the User signs in. +|Because the code goes wherever the redirect URL points, OBP checks every redirect URL when a Consumer is created or its redirect URL is updated. +| +|The redirect URL field can hold several URLs separated by commas or spaces. Each one must: +| +|* use https with a host, or +|* use http only for localhost, 127.0.0.1 or [::1] (for development), or +|* use an app scheme in reverse-domain form, such as com.example.app:/callback +| +|and must not contain a wildcard (*), user information (user@host) or a fragment (#). A redirect URL that breaks these rules is refused with OBP-20108. +| +|## Allowed hosts +| +|${code.api.util.RedirectUrlValidation.allowedHostsDescription} +| +|An instance can optionally limit the hosts redirect URLs may point to. When it does, each https or http redirect URL must name one of the listed hosts, +|or a host under a listed domain, and the API's own host is always allowed. A redirect URL whose host is not listed is refused with OBP-20109. +|The host list is checked when a Consumer's redirect URL is created or updated. App schemes are not affected, and neither are +|Berlin Group dynamic registration and TPP-Redirect-URI headers, which belong to third parties by definition, nor redirect URLs already stored. +| +|## Redirect URLs saved before these checks +| +|A Consumer saved before these checks existed may still hold a redirect URL that breaks them. Such an entry is left out when an OIDC provider reads the Consumer, +|so it is never used as a redirect target, and the Consumer's owner should update it. +|""") + glossaryItems += GlossaryItem( title = "Berlin Group Mandatory Headers", description = @@ -3311,6 +3341,12 @@ object Glossary extends MdcLoggable { | |* **TPP-Redirect-URI** - URI to redirect the PSU to after consent authorization | +|Whenever TPP-Redirect-URI or TPP-Nok-Redirect-URI is sent, on any Berlin Group request, its value must meet the redirect URL rules: +|https with a host; http only for localhost, 127.0.0.1 or [::1]; or an app scheme in reverse-domain form such as com.example.app. +|It must not contain a wildcard, user information or a fragment. A value that breaks them is refused with HTTP 400 (OBP-20108). +| +|On this instance a signature that does not cover TPP-Redirect-URI and TPP-Nok-Redirect-URI is ${if (APIUtil.getPropsAsBoolValue("berlin_group_require_signed_tpp_redirect_uri", false)) "refused: when either header is sent, the Signature header's headers list must include it" else "accepted"}. +| |## TPP Requests Without PSU Involvement | |For background/batch requests where no PSU is directly involved, set: @@ -3327,6 +3363,7 @@ object Glossary extends MdcLoggable { | |* `berlin_group_mandatory_headers` - Comma-separated list of mandatory header names. Set to empty to disable header checks. |* `berlin_group_mandatory_header_consent` - Additional headers required for consent creation endpoints. +|* `berlin_group_require_signed_tpp_redirect_uri` - When true, a request that sends TPP-Redirect-URI or TPP-Nok-Redirect-URI must list that header in its Signature header. Default false. | |Example Props configuration: | @@ -3346,10 +3383,12 @@ object Glossary extends MdcLoggable { |2. **Date format check** - Validates the Date header conforms to RFC 7231 |3. **X-Request-ID format check** - Validates the X-Request-ID is a valid UUID |4. **X-Request-ID uniqueness check** - Ensures the X-Request-ID has not been used in a previous successful POST (201) request -|5. **Signature header check** - Parses the Signature header and verifies the keyId serial number matches the TPP certificate -|6. **Consent-ID usage check** - Ensures the Consent-ID header is not sent on consent management endpoints where it is not expected +|5. **TPP redirect URI check** - Refuses a TPP-Redirect-URI or TPP-Nok-Redirect-URI that breaks the redirect URL rules (OBP-20108) +|6. **Signed TPP redirect URI check** - When `berlin_group_require_signed_tpp_redirect_uri` is true, refuses a request whose signature does not cover the TPP redirect headers it sends +|7. **Signature header check** - Parses the Signature header and verifies the keyId serial number matches the TPP certificate +|8. **Consent-ID usage check** - Ensures the Consent-ID header is not sent on consent management endpoints where it is not expected | -|If any check fails, OBP returns an appropriate error message (OBP-20251 through OBP-20256) with HTTP status 400. +|If any check fails, OBP returns an appropriate error message (OBP-20251 through OBP-20256, or OBP-20108 for a TPP redirect URI) with HTTP status 400. | """) diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index 5a4386b548..399eeec3dd 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -79,6 +79,7 @@ import code.webhook.AccountWebhook import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.dto.{CustomerAndAttribute, GetProductsParam, ProductCollectionItemsTree} import com.openbankproject.commons.model._ +import code.context.UserAuthContextUpdateProvider import com.openbankproject.commons.model.enums.StrongCustomerAuthentication.SCA import com.openbankproject.commons.model.enums.StrongCustomerAuthenticationStatus.SCAStatus import com.openbankproject.commons.model.enums.{SuppliedAnswerType, _} @@ -933,10 +934,18 @@ object NewStyle extends MdcLoggable{ } map { unboxFull(_) } } - def getCounterpartyTrait(bankId : BankId, accountId : AccountId, counterpartyId : String, callContext: Option[CallContext]): OBPReturnType[CounterpartyTrait] = + /** + * This returns the counterparty COUNTERPARTY_ID only if it belongs to the account BANK_ID / ACCOUNT_ID. + * A counterparty id is unique on its own, but the caller's access is checked for one account, so a + * counterparty of any other account must be treated as not found. The check is made here, whatever + * the connector returns, so that every endpoint which names a bank and account is held to them. + */ + def getCounterpartyTrait(bankId : BankId, accountId : AccountId, counterpartyId : String, callContext: Option[CallContext]): OBPReturnType[CounterpartyTrait] = { Connector.connector.vend.getCounterpartyTrait(bankId, accountId, counterpartyId, callContext) map { i => - (unboxFullOrFail(i._1, callContext, s"$CounterpartyNotFoundByCounterpartyId Current counterpartyId($counterpartyId) ", 400), + val counterpartyOfThisAccount = i._1.filter(counterparty => + counterparty.thisBankId == bankId.value && counterparty.thisAccountId == accountId.value) + (unboxFullOrFail(counterpartyOfThisAccount, callContext, s"$CounterpartyNotFoundByCounterpartyId Current counterpartyId($counterpartyId) ", 400), i._2) } } @@ -4706,6 +4715,44 @@ object NewStyle extends MdcLoggable{ } } + /** + * This answers the challenge of a User Auth Context Update on behalf of the User who requested it, and is + * shared by the v3.1.0 and v5.0.0 answer endpoints. + * + * The update must belong to the answering User. Otherwise any logged-in User could answer, or simply use up, + * the challenge of someone else's update, so an update of another User is reported exactly like one that does + * not exist. Only an ACCEPTED answer (the right one-time password) has any effect: it creates the User Auth + * Context and, for the key CUSTOMER_NUMBER, links the User to the Customer with that number at the bank. A + * REJECTED answer changes nothing, so a wrong answer can never link a User to someone else's Customer. + */ + def answerUserAuthContextUpdateChallenge( + bankId: BankId, + authContextUpdateId: String, + answer: String, + answeringUser: User, + callContext: Option[CallContext] + ): OBPReturnType[UserAuthContextUpdate] = { + val updatesOfTheAnsweringUser = UserAuthContextUpdateProvider.userAuthContextUpdateProvider.vend + .getUserAuthContextUpdatesBox(answeringUser.userId).toList.flatten + for { + _ <- Helper.booleanToFuture(UserAuthContextUpdateNotFound, cc = callContext) { + updatesOfTheAnsweringUser.exists(_.userAuthContextUpdateId == authContextUpdateId) + } + (userAuthContextUpdate, callContext) <- checkAnswer(authContextUpdateId, answer, callContext) + callContext <- if (userAuthContextUpdate.status == UserAuthContextUpdateStatus.ACCEPTED.toString) { + val key = userAuthContextUpdate.key.trim + val value = userAuthContextUpdate.value.trim + for { + (_, callContext) <- createUserAuthContext(answeringUser, key, value, callContext) + callContext <- key match { + case "CUSTOMER_NUMBER" => getOCreateUserCustomerLink(bankId, value, answeringUser.userId, callContext).map(_._2) + case _ => Future.successful(callContext) + } + } yield callContext + } else Future.successful(callContext) + } yield (userAuthContextUpdate, callContext) + } + def sendCustomerNotification( scaMethod: StrongCustomerAuthentication, recipient: String, diff --git a/obp-api/src/main/scala/code/api/util/RedirectUrlValidation.scala b/obp-api/src/main/scala/code/api/util/RedirectUrlValidation.scala new file mode 100644 index 0000000000..2bb02f1ea5 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/RedirectUrlValidation.scala @@ -0,0 +1,176 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, 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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.util + +import java.net.URI + +import code.api.util.ErrorMessages.{InvalidRedirectUrl, RedirectUrlHostNotAllowed} +import code.util.Helper +import net.liftweb.common.Box + +import scala.concurrent.Future +import scala.util.Try + +/** + * This object decides whether a redirect URL may be stored on a Consumer, or used as a Berlin Group + * TPP redirect. + * + * An OIDC provider (OBP-OIDC) sends the browser, together with an authorisation code, to whichever + * redirect URL is registered on the Consumer. A registered `javascript:` URL runs script on the + * provider's page, and a URL with a wildcard, user information or a plain-http public host hands the + * code to whoever controls that address. So every redirect URL is checked when it is written, not only + * when it is used. + * + * A Consumer's redirect URL field can hold several URLs separated by commas or whitespace, the same way + * OBP-OIDC and `getOidcClient` split it. Every entry must pass. The rules are: + * + * - `https:` with a host; + * - `http:` only for `localhost`, `127.0.0.1` or `[::1]`, for development; + * - a private-use app scheme in reverse-domain form (RFC 8252), such as `com.example.app:/callback`; + * - never a wildcard (`*`), user information (`user@host`) or a fragment (`#...`). + * + * An empty value is allowed: Consumers that only use DirectLogin or client credentials have no redirect. + * + * An instance can also limit which hosts a Consumer's redirect URL may point to, with the optional prop + * `redirect_url_allowed_hosts`. Empty (the default) puts no limit on hosts. When it is set, every https or + * http entry must name a listed host, where `portal.example.com` matches that host exactly and + * `.example.com` matches example.com and any host under it; the API's own host is always included. App + * schemes have no host to check and are not affected. The host list is checked when an endpoint writes a + * Consumer's redirect URL. It is not applied to Berlin Group dynamic registration or TPP redirect headers, + * which by definition belong to third parties, nor to redirect URLs already stored. + */ +object RedirectUrlValidation { + + private val loopbackHosts = Set("localhost", "127.0.0.1", "[::1]") + + // A reverse-domain scheme: at least two dot-separated labels, e.g. com.example.app or x-com.example.ios. + private val reverseDomainScheme = """^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+$""".r + + /** + * The hosts a Consumer redirect URL may point to on this instance, or Nil when there is no limit. + * This is a def, not a val, so a change to the prop (in tests, or after a restart) is always seen. + */ + def allowedHosts: List[String] = { + val listed = APIUtil.getPropsValue("redirect_url_allowed_hosts", "") + .split("[,\\s]+").map(_.trim.toLowerCase).filter(_.nonEmpty).toList + if (listed.isEmpty) Nil + else { + val ownHost = APIUtil.getPropsValue("hostname").toOption + .flatMap(hostname => Try(new URI(hostname)).toOption.flatMap(uri => Option(uri.getHost))) + .map(_.toLowerCase) + (listed ++ ownHost).distinct + } + } + + private def hostIsAllowed(host: String, allowed: List[String]): Boolean = { + val lowerCaseHost = host.toLowerCase + allowed.exists { allowedHost => + if (allowedHost.startsWith(".")) lowerCaseHost == allowedHost.drop(1) || lowerCaseHost.endsWith(allowedHost) + else lowerCaseHost == allowedHost + } + } + + /** + * Returns why an entry that already meets the redirect URL rules points to a host this instance does not + * allow, or None when there is no host list, the host is listed, or the entry is an app scheme. + */ + def hostProblemWith(entry: String): Option[String] = allowedHosts match { + case Nil => None + case allowed => + Try(new URI(entry)).toOption + .filter(uri => Option(uri.getScheme).map(_.toLowerCase).exists(scheme => scheme == "https" || scheme == "http")) + .flatMap(uri => Option(uri.getHost)) + .filterNot(hostIsAllowed(_, allowed)) + .map(host => s"the host '$host' is not one of the hosts this instance allows: ${allowed.mkString(", ")}") + } + + /** One sentence for the Glossary and the boot log saying whether this instance limits redirect URL hosts. */ + def allowedHostsDescription: String = allowedHosts match { + case Nil => "On this instance a redirect URL may point to any host that meets the redirect URL rules." + case allowed => s"On this instance a redirect URL must point to one of these hosts: ${allowed.mkString(", ")}." + } + + /** Splits a stored redirect URL value into its entries, the same way OBP-OIDC does. */ + def entries(redirectUrls: String): List[String] = + Option(redirectUrls).toList.flatMap(_.split("[,\\s]+").toList).map(_.trim).filter(_.nonEmpty) + + /** Returns why one redirect URL entry is not allowed, or None when it is allowed. */ + def problemWith(entry: String): Option[String] = { + if (entry.contains("*")) Some("a wildcard is not allowed") + else Try(new URI(entry)).toOption match { + case None => Some("it is not a valid URI") + case Some(uri) if uri.getScheme == null => Some("it has no scheme") + case Some(uri) if uri.getRawFragment != null => Some("a fragment is not allowed") + case Some(uri) if uri.getRawUserInfo != null => Some("user information is not allowed") + case Some(uri) => + uri.getScheme.toLowerCase match { + case "https" => + if (Option(uri.getHost).exists(_.nonEmpty)) None + else Some("an https URL must name a host") + case "http" => + if (Option(uri.getHost).map(_.toLowerCase).exists(loopbackHosts.contains)) None + else Some("http is only allowed for localhost, 127.0.0.1 or [::1]; use https") + case scheme if reverseDomainScheme.pattern.matcher(scheme).matches() => + None + case scheme => + Some(s"the scheme '$scheme' is not allowed; use https, or an app scheme in reverse-domain form such as com.example.app") + } + } + } + + /** Describes the first entry that is not allowed, or None when every entry is allowed. */ + def firstProblem(redirectUrls: String): Option[String] = + entries(redirectUrls).iterator + .map(entry => problemWith(entry).map(reason => s"'$entry': $reason")) + .collectFirst { case Some(description) => description } + + def isValid(redirectUrls: String): Boolean = firstProblem(redirectUrls).isEmpty + + /** + * The full error message for the first entry of a Consumer redirect URL value that is not allowed, or None + * when every entry is allowed. An entry that breaks the rules gives InvalidRedirectUrl. With + * `applyHostList`, an entry whose host is not on this instance's list gives RedirectUrlHostNotAllowed. + */ + def consumerRedirectUrlError(redirectUrls: String, applyHostList: Boolean): Option[String] = + entries(redirectUrls).iterator.map { entry => + problemWith(entry).map(reason => s"$InvalidRedirectUrl'$entry': $reason") + .orElse(if (applyHostList) hostProblemWith(entry).map(reason => s"$RedirectUrlHostNotAllowed'$entry': $reason") else None) + }.collectFirst { case Some(message) => message } + + /** The entries that meet the rules, for readers that must skip invalid stored data. */ + def validEntries(redirectUrls: String): List[String] = entries(redirectUrls).filter(problemWith(_).isEmpty) + + /** + * Fails with 400, naming the offending entry, when any entry of a Consumer redirect URL value is not allowed. + * `applyHostList` is false only for Berlin Group dynamic registration, whose TPPs use their own domains. + */ + def checkRedirectUrls(redirectUrls: String, callContext: Option[CallContext], applyHostList: Boolean = true): Future[Box[Unit]] = { + val error = consumerRedirectUrlError(redirectUrls, applyHostList) + Helper.booleanToFuture(error.getOrElse(""), 400, callContext) { error.isEmpty } + } +} diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 8a7897581c..8808f99f66 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -743,7 +743,9 @@ object ResourceDocMiddleware extends MdcLoggable { case Right((cp, Some(updatedCC))) => IO.pure(Right(ctx.copy(counterparty = Some(cp), callContext = updatedCC))) case Right((cp, None)) => IO.pure(Right(ctx.copy(counterparty = Some(cp)))) case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_)) - case Left(_) => ErrorResponseConverter.createErrorResponse(404, CounterpartyNotFound + s": counterpartyId=$counterpartyId", ctx.callContext).map(Left(_)) + // The lookup's own failure: 400 CounterpartyNotFoundByCounterpartyId when the counterparty doesn't + // exist or belongs to another account, the code Lift's COUNTERPARTY_ID check answered with. + case Left(e) => ErrorResponseConverter.toHttp4sResponse(e, ctx.callContext).map(Left(_)) } ) case _ => DSL.success(ctx) diff --git a/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala b/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala index f6b69009a1..addf318cd9 100644 --- a/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala +++ b/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala @@ -333,12 +333,15 @@ object Http4s140 { "GET", "/banks/BANK_ID/crm-events", "Get CRM Events", - "", + s"""Returns every CRM event recorded at the bank BANK_ID, for all customers. Each event includes the customer's + |name and number, the category, channel, scheduled and actual dates, and the detail and result notes. + | + |Authentication is Required, and the User needs the Role ${canGetCrmEvents} at the bank.""".stripMargin, EmptyBody, crmEventsJson, - List(AuthenticatedUserIsRequired, BankNotFound, "No CRM Events available.", UnknownError), + List(AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, "No CRM Events available.", UnknownError), List(apiTagCustomer), - None, + Some(List(canGetCrmEvents)), http4sPartialFunction = Some(getCrmEvents) ) diff --git a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala index b4b6e63cfd..7bce7ab09a 100644 --- a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala +++ b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala @@ -49,6 +49,7 @@ import code.api.v2_0_0.JSONFactory200 import code.api.v2_0_0.JSONFactory200._ import code.customer.CustomerX import code.entitlement.Entitlement +import code.bankconnectors.Connector import code.model.dataAccess.{AuthUser, BankAccountCreation} import code.model.{BankAccountX, BankExtended, UserX, _} import code.search.{elasticsearchMetrics, elasticsearchWarehouse} @@ -864,14 +865,19 @@ object Http4s200 { val createAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ => EndpointHelpers.withUserAndBankAndBody[CreateAccountJSON, CoreAccountJSON](req) { (user, bank, body, cc) => + val accountId = cc.bankAccount.map(_.accountId).getOrElse( + AccountId(req.uri.path.segments.lastOption.map(_.encoded).getOrElse(""))) for { _ <- code.util.Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { - isValidID(cc.bankAccount.map(_.accountId.value).getOrElse( - req.uri.path.segments.lastOption.map(_.encoded).getOrElse(""))) + isValidID(accountId.value) } _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(bank.bankId.value) } + // An existing ACCOUNT_ID must be refused: creating it again would return the existing account + // and make the caller one of its holders. + (existingAccount, _) <- Connector.connector.vend.checkBankAccountExists(bank.bankId, accountId, Some(cc)) + _ <- code.util.Helper.booleanToFuture(AccountIdAlreadyExists, failCode = 409, cc = Some(cc)) { existingAccount.isEmpty } loggedInUserId = user.userId // Implicit owner resolves to the HUMAN: under a Consent the caller is the // per-consent shadow, and an account held by it strands when the consent dies. @@ -895,8 +901,6 @@ object Http4s200 { _ <- code.util.Helper.booleanToFuture(InvalidISOCurrencyCode, cc = cc2) { isValidCurrencyISOCode(body.balance.currency) } - accountId = cc.bankAccount.map(_.accountId).getOrElse( - AccountId(req.uri.path.segments.lastOption.map(_.encoded).getOrElse(""))) (bankAccount, cc3) <- NewStyle.function.createBankAccount( bank.bankId, accountId, body.`type`, body.label, body.balance.currency, initialBalanceAsNumber, postedOrLoggedInUser.name, "", List.empty, cc2) @@ -926,7 +930,7 @@ object Http4s200 { coreAccountJSON, List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidUserId, InvalidAccountIdFormat, InvalidBankIdFormat, UserNotFoundById, InvalidAccountBalanceAmount, InvalidAccountType, InvalidAccountInitialBalance, - InvalidAccountBalanceCurrency, UnknownError), + InvalidAccountBalanceCurrency, AccountIdAlreadyExists, UnknownError), List(apiTagAccount, apiTagOldStyle), None, http4sPartialFunction = Some(createAccount)) @@ -938,10 +942,15 @@ object Http4s200 { val getTransactionTypes: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / _ / "transaction-types" => EndpointHelpers.withBank(req) { (bank, cc) => - Future { - val types = TransactionType.TransactionTypeProvider.vend.getTransactionTypesForBank(bank.bankId) - JSONFactory200.createTransactionTypeJSON(connectorEmptyResponse(types, Some(cc))) - } + // The prop is read per request as well as at boot: the ResourceDoc (built at boot) only makes the + // middleware require a login when the prop was false then, and this check covers the handler itself. + val isPublic = APIUtil.getPropsAsBoolValue("apiOptions.getTransactionTypesIsPublic", true) + for { + _ <- if (!isPublic) + code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined } + else Future.unit + types <- Future(TransactionType.TransactionTypeProvider.vend.getTransactionTypesForBank(bank.bankId)) + } yield JSONFactory200.createTransactionTypeJSON(connectorEmptyResponse(types, Some(cc))) } } @@ -963,7 +972,7 @@ object Http4s200 { | |${userAuthenticationMessage(!getTransactionTypesIsPublic)}""".stripMargin, EmptyBody, transactionTypesJsonV200, - List(BankNotFound, UnknownError), + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), List(apiTagBank, apiTagPSD2AIS, apiTagPsd2), None, http4sPartialFunction = Some(getTransactionTypes)) diff --git a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala index 61337055c8..b3e364c20d 100644 --- a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala +++ b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala @@ -101,8 +101,6 @@ object Http4s210 { // and we keep the bug-for-bug compatibility so the restoration runs // verbatim against Lift's source-of-truth. private val createCustomeEntitlementsRequiredText: String = createCustomerEntitlementsRequiredText - private val getTransactionTypesIsPublic = - APIUtil.getPropsAsBoolValue("apiOptions.getTransactionTypesIsPublic", true) object Implementations2_1_0 { val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString @@ -357,8 +355,8 @@ object Http4s210 { s"${InvalidJsonFormat}, it should be $COUNTERPARTY json format", 400, Some(cc)) { parsedJson.extract[TransactionRequestBodyCounterpartyJSON] } - (toCounterparty, _) <- NewStyle.function.getCounterpartyByCounterpartyId( - CounterpartyId(body.to.counterparty_id), Some(cc)) + (toCounterparty, _) <- NewStyle.function.getCounterpartyTrait( + fromAccount.bankId, fromAccount.accountId, body.to.counterparty_id, Some(cc)) (toAccount, _) <- NewStyle.function.getBankAccountFromCounterparty(toCounterparty, true, Some(cc)) _ <- code.util.Helper.booleanToFuture(s"$CounterpartyBeneficiaryPermit", cc = Some(cc)) { toCounterparty.isBeneficiary @@ -908,7 +906,7 @@ object Http4s210 { | * description : A longer description | * charge : The charge to the customer for each one of these | - |${userAuthenticationMessage(getTransactionTypesIsPublic)}""".stripMargin, + |${userAuthenticationMessage(true)}""".stripMargin, transactionTypeJsonV200, transactionType, List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, InsufficientAuthorisationToCreateTransactionType, UnknownError), @@ -1269,13 +1267,6 @@ object Http4s210 { case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "redirect_url" => EndpointHelpers.withUserAndBody[ConsumerRedirectUrlJSON, ConsumerJsonV210](req) { (user, body, cc) => for { - _ <- APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false) match { - case true => Future.unit - case false => - code.util.Helper.booleanToFuture(UserHasMissingRoles + canUpdateConsumerRedirectUrl, failCode = 403, cc = Some(cc)) { - APIUtil.hasEntitlement("", user.userId, canUpdateConsumerRedirectUrl) - } - } consumerIdLong <- NewStyle.function.tryons(InvalidConsumerId, 400, Some(cc)) { consumerId.toLong } @@ -1283,6 +1274,7 @@ object Http4s210 { _ <- code.util.Helper.booleanToFuture(UserNoPermissionUpdateConsumer, failCode = 400, cc = Some(cc)) { consumer.createdByUserId.equals(user.userId) } + _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(body.redirect_url, Some(cc)) updatedConsumer <- NewStyle.function.updateConsumer( id = consumer.id.get, isActive = Some(APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false)), @@ -1305,9 +1297,9 @@ object Http4s210 { | """.stripMargin, consumerRedirectUrlJSON, consumerJSON, - List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError), List(apiTagConsumer), - None, + Some(List(canUpdateConsumerRedirectUrl)), http4sPartialFunction = Some(updateConsumerRedirectUrl)) // ─── getMetrics ─────────────────────────────────────────────────────────── diff --git a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala index 71dc927940..436b1e70cf 100644 --- a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala +++ b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala @@ -683,6 +683,10 @@ object Http4s220 { } _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(bank.bankId.value) } accountId = AccountId(accountIdStr) + // An existing ACCOUNT_ID must be refused: creating it again would return the existing account + // and make the caller one of its holders. + (existingAccount, _) <- Connector.connector.vend.checkBankAccountExists(bank.bankId, accountId, Some(cc)) + _ <- code.util.Helper.booleanToFuture(AccountIdAlreadyExists, failCode = 409, cc = Some(cc)) { existingAccount.isEmpty } loggedInUserId = user.userId userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else loggedInUserId (postedOrLoggedInUser, _) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) @@ -823,6 +827,7 @@ object Http4s220 { NewStyle.function.ownEntitlement("", user.userId, canCreateConsumer, Some(cc)), Some(cc), UserHasMissingRoles + canCreateConsumer) } + _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(body.redirect_url, Some(cc)) consumer <- Future { Consumers.consumers.vend.createConsumer( Some(generateUUID()), Some(generateUUID()), @@ -852,7 +857,7 @@ object Http4s220 { "Test", "Description", "some@email.com", - "redirecturl", + "https://app.example.com/callback", "createdby", true, new Date(), @@ -865,7 +870,7 @@ object Http4s220 { "App type", "Description", "some.email@example.com", - "Some redirect url", + "https://app.example.com/callback", "Created by UUID", true, new Date(), @@ -873,7 +878,7 @@ object Http4s220 { |client_certificate_content |-----END CERTIFICATE-----""".stripMargin ), - List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError), List(apiTagConsumer, apiTagOldStyle), Some(List(canCreateConsumer)), http4sPartialFunction = Some(createConsumer)) diff --git a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala index f293021eba..59ebb0b506 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala @@ -3904,21 +3904,9 @@ object Http4s310 { case req @ POST -> `prefixPath` / "banks" / bankIdStr / "users" / "current" / "auth-context-updates" / authContextUpdateIdStr / "challenge" => EndpointHelpers.executeFutureWithBody[PostUserAuthContextUpdateJsonV310, Any](req) { (postBody, cc) => for { - (userAuthContextUpdate, _) <- NewStyle.function.checkAnswer(authContextUpdateIdStr, postBody.answer, Some(cc)) - (subjectUser, _) <- NewStyle.function.getUserByUserId(userAuthContextUpdate.userId, Some(cc)) - _ <- userAuthContextUpdate.status match { - case status if status == com.openbankproject.commons.model.UserAuthContextUpdateStatus.ACCEPTED.toString => - NewStyle.function.createUserAuthContext( - subjectUser, userAuthContextUpdate.key, userAuthContextUpdate.value, Some(cc) - ).map(x => (Some(x._1), x._2)) - case _ => Future((None, Some(cc))) - } - _ <- userAuthContextUpdate.key match { - case "CUSTOMER_NUMBER" => - NewStyle.function.getOCreateUserCustomerLink( - BankId(bankIdStr), userAuthContextUpdate.value, subjectUser.userId, Some(cc)) - case _ => Future((None, Some(cc))) - } + (userAuthContextUpdate, _) <- NewStyle.function.answerUserAuthContextUpdateChallenge( + BankId(bankIdStr), authContextUpdateIdStr, postBody.answer, + cc.user.openOrThrowException(AuthenticatedUserIsRequired), Some(cc)) } yield createUserAuthContextUpdateJson(userAuthContextUpdate) } } diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index e405fdb17c..5e5a61d197 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -2852,8 +2852,8 @@ object Http4s400 { failMsg = s"${NoViewPermission}can_get_counterparty", failCode = 403, cc = Some(cc)) { view.allowed_actions.exists(_ == CAN_GET_COUNTERPARTY) } - (counterparty, _) <- NewStyle.function.getCounterpartyByCounterpartyId( - CounterpartyId(counterpartyIdStr), Some(cc)) + (counterparty, _) <- NewStyle.function.getCounterpartyTrait( + account.bankId, account.accountId, counterpartyIdStr, Some(cc)) counterpartyMetadata <- NewStyle.function.getMetadata( account.bankId, account.accountId, counterparty.counterpartyId, Some(cc)) } yield JSONFactory400.createCounterpartyWithMetadataJson400(counterparty, counterpartyMetadata) @@ -2862,7 +2862,7 @@ object Http4s400 { staticResourceDocs += ResourceDoc( implementedInApiVersion, "getExplicitCounterpartyById", "GET", - "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/EXPLICIT_COUNTERPARTY_ID", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID", "Get Counterparty by Id (Explicit)", s"""This endpoint returns a single Counterparty on an Account View specified by its COUNTERPARTY_ID: | @@ -2873,7 +2873,7 @@ object Http4s400 { |""".stripMargin, EmptyBody, counterpartyWithMetadataJson400, List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, - $UserNoPermissionAccessView, UnknownError), + $UserNoPermissionAccessView, $CounterpartyNotFoundByCounterpartyId, UnknownError), List(apiTagCounterparty, apiTagPSD2PIS, apiTagPsd2, apiTagCounterpartyMetaData), None, http4sPartialFunction = Some(getExplicitCounterpartyById)) @@ -3369,6 +3369,7 @@ object Http4s400 { InvalidTransactionRequestCurrency, PaymentChallengeHasNoOnBehalfOfUser, TransactionDisabled, + CounterpartyNotFoundByCounterpartyId, RefundTransactionNotWithThisParty, RefundedTransaction, UnknownError ), List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, @@ -3566,6 +3567,21 @@ object Http4s400 { } } + /** + * This says whether a transaction request involves the account BANK_ID / ACCOUNT_ID, either as the + * account it pays from or as the account it pays to. The pays-to side matters for a REFUND that + * asks another party for money back: the requester answers its challenge on their own account, + * which is the request's pays-to account. The pays-to account is read from the stored request, + * because the TransactionRequest model only exposes it for some types. + */ + private def transactionRequestInvolvesAccount(transactionRequest: TransactionRequest, bankId: BankId, accountId: AccountId): Boolean = { + val paysFrom = transactionRequest.from.bank_id == bankId.value && transactionRequest.from.account_id == accountId.value + lazy val paysTo = code.transactionrequests.MappedTransactionRequest + .find(net.liftweb.mapper.By(code.transactionrequests.MappedTransactionRequest.mTransactionRequestId, transactionRequest.id.value)) + .exists(stored => stored.mTo_BankId.get == bankId.value && stored.mTo_AccountId.get == accountId.value) + paysFrom || paysTo + } + private def answerTransactionRequestChallengeImpl( user: User, fromAccount: BankAccount, @@ -3597,6 +3613,12 @@ object Http4s400 { code.bankconnectors.DoobieTransactionRequestQueries.lockTransactionRequest(transReqId.value).isDefined } (existingTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(transReqId, Some(cc)) + // The transaction request is found by its id alone, but the caller was authorised only on the + // account in the URL, so the request must involve that account: as the account it pays from, + // or (for a REFUND the caller asked for) as the account it pays to. + _ <- code.util.Helper.booleanToFuture(s"$InvalidTransactionRequestId Current TRANSACTION_REQUEST_ID(${transReqId.value})", cc = Some(cc)) { + transactionRequestInvolvesAccount(existingTransactionRequest, fromAccount.bankId, fromAccount.accountId) + } _ <- code.util.Helper.booleanToFuture( TransactionRequestStatusNotInitiatedOrPendingOrForwarded, cc = Some(cc)) { existingTransactionRequest.status.equals(TransactionRequestStatus.INITIATED.toString) || @@ -3781,7 +3803,8 @@ object Http4s400 { AllowedAttemptsUsedUp, ChallengeNotAddressedToCaller, TransactionDisabled, - UnknownError + InvalidTransactionRequestId, + UnknownError ), List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, http4sPartialFunction = Some(answerTransactionRequestChallenge)) @@ -3951,8 +3974,8 @@ object Http4s400 { case req @ GET -> `prefixPath` / "management" / "banks" / _ / "accounts" / _ / _ / "counterparties" / counterpartyIdStr => EndpointHelpers.withBankAccount(req) { (_, account, cc) => for { - (counterparty, _) <- NewStyle.function.getCounterpartyByCounterpartyId( - CounterpartyId(counterpartyIdStr), Some(cc)) + (counterparty, _) <- NewStyle.function.getCounterpartyTrait( + account.bankId, account.accountId, counterpartyIdStr, Some(cc)) counterpartyMetadata <- NewStyle.function.getMetadata( account.bankId, account.accountId, counterpartyIdStr, Some(cc)) } yield JSONFactory400.createCounterpartyWithMetadataJson400(counterparty, counterpartyMetadata) @@ -4008,7 +4031,7 @@ object Http4s400 { implementedInApiVersion, nameOf(getCounterpartyByIdForAnyAccount), "GET", - "/management/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID_PARAM", + "/management/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID", "Get Counterparty by Id for any account (Explicit)", s"""This is a management endpoint that gets information about any single explicitly created Counterparty on an Account / View specified by its COUNTERPARTY_ID", | @@ -4022,7 +4045,7 @@ object Http4s400 { EmptyBody, counterpartyWithMetadataJson400, List($AuthenticatedUserIsRequired, InvalidAccountIdFormat, InvalidBankIdFormat, - $BankNotFound, $BankAccountNotFound, InvalidJsonFormat, ViewNotFound, UnknownError), + $BankNotFound, $BankAccountNotFound, InvalidJsonFormat, ViewNotFound, $CounterpartyNotFoundByCounterpartyId, UnknownError), List(apiTagCounterparty, apiTagAccount), Some(List(canGetCounterpartyAtAnyBank, canGetCounterparty)), http4sPartialFunction = Some(getCounterpartyByIdForAnyAccount) @@ -4320,7 +4343,7 @@ object Http4s400 { implementedInApiVersion, nameOf(deleteExplicitCounterparty), "DELETE", - "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID_PARAM", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID", "Delete Counterparty (Explicit)", s"""This endpoint deletes the Counterparty on the Account / View specified by the COUNTERPARTY_ID. |It also deletes any related Counterparty Metadata. @@ -4341,7 +4364,7 @@ object Http4s400 { $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, - UnknownError + $CounterpartyNotFoundByCounterpartyId, UnknownError ), List(apiTagCounterparty, apiTagAccount), None, @@ -4364,7 +4387,7 @@ object Http4s400 { EmptyBody, EmptyBody, List($AuthenticatedUserIsRequired, $BankAccountNotFound, $BankNotFound, - InvalidAccountIdFormat, InvalidBankIdFormat, UserHasMissingRoles, UnknownError), + InvalidAccountIdFormat, InvalidBankIdFormat, UserHasMissingRoles, $CounterpartyNotFoundByCounterpartyId, UnknownError), List(apiTagCounterparty, apiTagAccount), Some(List(canDeleteCounterparty, canDeleteCounterpartyAtAnyBank)), http4sPartialFunction = Some(deleteCounterpartyForAnyAccount) @@ -5561,8 +5584,8 @@ object Http4s400 { _ <- code.users.Users.users.vend.getUserByUserIdFuture(postJson.user_id) map { box => unboxFullOrFail(box, Some(cc), s"$UserNotFoundByUserId Current UserId(${postJson.user_id})") } - (_, _) <- NewStyle.function.getCounterpartyByCounterpartyId( - CounterpartyId(postJson.counterparty_id), Some(cc)) + (_, _) <- NewStyle.function.getCounterpartyTrait( + BankId(bankIdStr), AccountId(accountIdStr), postJson.counterparty_id, Some(cc)) (directDebit, _) <- NewStyle.function.createDirectDebit( bankIdStr, accountIdStr, postJson.customer_id, postJson.user_id, postJson.counterparty_id, @@ -5615,8 +5638,8 @@ object Http4s400 { _ <- code.users.Users.users.vend.getUserByUserIdFuture(postJson.user_id) map { box => unboxFullOrFail(box, Some(cc), s"$UserNotFoundByUserId Current UserId(${postJson.user_id})") } - (_, _) <- NewStyle.function.getCounterpartyByCounterpartyId( - CounterpartyId(postJson.counterparty_id), Some(cc)) + (_, _) <- NewStyle.function.getCounterpartyTrait( + BankId(bankIdStr), AccountId(accountIdStr), postJson.counterparty_id, Some(cc)) (order, _) <- NewStyle.function.createStandingOrder( bankIdStr, accountIdStr, postJson.customer_id, postJson.user_id, postJson.counterparty_id, amountValue, postJson.amount.currency, @@ -5791,7 +5814,7 @@ object Http4s400 { CustomerNotFoundByCustomerId, UserNotFoundByUserId, $UserNoPermissionAccessView, - UnknownError + CounterpartyNotFoundByCounterpartyId, UnknownError ), List(apiTagStandingOrder, apiTagAccount), None, @@ -5815,7 +5838,7 @@ object Http4s400 { standingOrderJsonV400, List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, NoViewPermission, InvalidJsonFormat, InvalidNumber, InvalidISOCurrencyCode, - CustomerNotFoundByCustomerId, UserNotFoundByUserId, UnknownError), + CustomerNotFoundByCustomerId, UserNotFoundByUserId, CounterpartyNotFoundByCounterpartyId, UnknownError), List(apiTagStandingOrder, apiTagAccount), Some(List(canCreateStandingOrderAtOneBank)), http4sPartialFunction = Some(createStandingOrderManagement) @@ -10573,6 +10596,7 @@ object Http4s400 { } (postedJson, appType) = postedJsonAndAppType _ <- NewStyle.function.hasEntitlement("", u.userId, code.api.util.ApiRole.canCreateConsumer, Some(cc)) + _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(postedJson.redirect_url, Some(cc)) (consumer, callContext) <- createConsumerNewStyle( key = Some(LiftHelpers.randomString(40).toLowerCase), secret = Some(LiftHelpers.randomString(40).toLowerCase), @@ -10927,7 +10951,7 @@ object Http4s400 { "Web", "Description", "some@email.com", - "redirecturl", + "https://app.example.com/callback", "createdby", true, new Date(), @@ -10936,7 +10960,7 @@ object Http4s400 { |-----END CERTIFICATE-----""".stripMargin ), consumerJsonV400, - List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError), List(apiTagConsumer), Some(List(canCreateConsumer)), http4sPartialFunction = Some(createConsumer)) diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala index 2283aa4606..9e94a8e51f 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -828,22 +828,9 @@ object Http4s500 { postUserAuthContextUpdateJson <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("")).extract[PostUserAuthContextUpdateJsonV310] } - (userAuthContextUpdate, _) <- NewStyle.function.checkAnswer(authContextUpdateId, postUserAuthContextUpdateJson.answer, Some(cc)) - (user, _) <- NewStyle.function.getUserByUserId(userAuthContextUpdate.userId, Some(cc)) - _ <- userAuthContextUpdate.status match { - case status if status == UserAuthContextUpdateStatus.ACCEPTED.toString => - NewStyle.function.createUserAuthContext( - user, userAuthContextUpdate.key.trim, userAuthContextUpdate.value.trim, Some(cc)) - .map(x => (Some(x._1), x._2)) - case _ => - Future.successful((None, Some(cc))) - } - _ <- userAuthContextUpdate.key match { - case "CUSTOMER_NUMBER" => - NewStyle.function.getOCreateUserCustomerLink(bankId, userAuthContextUpdate.value, user.userId, Some(cc)) - case _ => - Future.successful((None, Some(cc))) - } + (userAuthContextUpdate, _) <- NewStyle.function.answerUserAuthContextUpdateChallenge( + bankId, authContextUpdateId, postUserAuthContextUpdateJson.answer, + cc.user.openOrThrowException(AuthenticatedUserIsRequired), Some(cc)) } yield JSONFactory500.createUserAuthContextUpdateJson(userAuthContextUpdate) } } diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 0c7b9d6573..a71190b215 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -658,6 +658,7 @@ object Http4s510 { (js, appType) } (postedJson, appType) = tup + _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(postedJson.redirect_url, Some(cc)) (consumer, _) <- createConsumerNewStyle( key = Some(Helpers.randomString(40).toLowerCase), secret = Some(Helpers.randomString(40).toLowerCase), @@ -677,6 +678,13 @@ object Http4s510 { } } + // For the Consumer docs that take a redirect_url: the rules it must meet and this instance's host list. + private val redirectUrlRulesText = + s"""**redirect_url** must meet the redirect URL rules (https; http only for localhost; or an app scheme in + |reverse-domain form; no wildcard, user information or fragment). ${code.api.util.RedirectUrlValidation.allowedHostsDescription} + |See ${Glossary.getGlossaryItemLink("Redirect URL")} + |""".stripMargin + // For the Create Consumer docs: how an app the installation runs itself gets the Roles its own calls need. private val platformAppConsumerText = s"""**If this Consumer is for an app your installation runs itself** (such as the Portal, the API Manager, @@ -770,6 +778,8 @@ object Http4s510 { | |**Important**: The key and secret are only shown once in the response. Save them securely as they cannot be retrieved later. | + |$redirectUrlRulesText + | |$platformAppConsumerText | |${consumerDisabledText()} @@ -779,7 +789,7 @@ object Http4s510 { |""", createConsumerRequestJsonV510, consumerJsonOnlyForPostResponseV510, - List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError), List(apiTagConsumer), Some(List(canCreateConsumer)), authMode = UserOrApplication, @@ -1353,14 +1363,24 @@ object Http4s510 { http4sPartialFunction = Some(logCacheAllEndpoint) ) + // The longest delay waitingForGodot will make. Even with the Role required, an unbounded delay would let + // one caller hold requests open for as long as they liked. + private def waitingForGodotMaxSleepInMilliseconds: Long = + APIUtil.getPropsAsLongValue("waiting_for_godot_max_sleep_in_milliseconds", 5000L).max(0L) + val waitingForGodot: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "waiting-for-godot" => - EndpointHelpers.executeFuture(req) { - val sleep = req.uri.query.params.get("sleep").getOrElse("0") - val sleepInMillis: Long = scala.util.Try(sleep.trim.toLong).getOrElse(0L) - for { _ <- Future(Thread.sleep(sleepInMillis)) } - yield JSONFactory510.waitingForGodot(sleepInMillis) - } + val requested = req.uri.query.params.get("sleep").getOrElse("0") + // Clamped to [0, max]: a negative value used to reach Thread.sleep and fail with a 500. + val sleepInMillis: Long = scala.util.Try(requested.trim.toLong).getOrElse(0L) + .max(0L).min(waitingForGodotMaxSleepInMilliseconds) + // IO.sleep waits on the cats-effect timer without occupying a thread. The old + // Future(Thread.sleep(...)) blocked a thread of the shared global pool (one thread per core) + // for the whole delay, so a few concurrent calls stopped every other endpoint using that pool. + IO.sleep(scala.concurrent.duration.Duration(sleepInMillis, java.util.concurrent.TimeUnit.MILLISECONDS)) *> + EndpointHelpers.executeFuture(req) { + Future.successful(JSONFactory510.waitingForGodot(sleepInMillis)) + } } resourceDocs += ResourceDoc( implementedInApiVersion, @@ -1368,16 +1388,23 @@ object Http4s510 { "GET", "/waiting-for-godot", "Waiting For Godot", - """Waiting For Godot + s"""Waiting For Godot + | + |A diagnostic endpoint for testing how clients, proxies and gateways handle a slow response. + | + |Authentication is Required, and the User needs the Role CanGetWaitingForGodot. | |Uses query parameter "sleep" in milliseconds. |For instance: .../waiting-for-godot?sleep=50 means postpone response in 50 milliseconds. + | + |On this instance the delay is at most ${waitingForGodotMaxSleepInMilliseconds} milliseconds: a larger value is reduced to that, + |and a negative or unreadable value counts as 0. The response reports the delay actually made. |""".stripMargin, EmptyBody, WaitingForGodotJsonV510(sleep_in_milliseconds = 50), - List(UnknownError, MandatoryPropertyIsNotSet), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), apiTagApi :: Nil, - None, + Some(List(canGetWaitingForGodot)), http4sPartialFunction = Some(waitingForGodot) ) @@ -2864,10 +2891,6 @@ object Http4s510 { implicit val cc: code.api.util.CallContext = req.callContext for { user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) - _ <- APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false) match { - case true => Future.successful(Full(())) - case false => NewStyle.function.hasEntitlement("", user.userId, ApiRole.canUpdateConsumerRedirectUrl, Some(cc)) - } postJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("")).extract[ConsumerRedirectUrlJSON] } @@ -2875,6 +2898,7 @@ object Http4s510 { _ <- Helper.booleanToFuture(UserNoPermissionUpdateConsumer, 400, Some(cc)) { consumer.createdByUserId.equals(user.userId) } + _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(postJson.redirect_url, Some(cc)) updatedConsumer <- NewStyle.function.updateConsumer( id = consumer.id.get, isActive = Some(APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", defaultValue = false)), @@ -2897,10 +2921,11 @@ object Http4s510 { | | Or use the endpoint 'Get Consumers' to get it | + |$redirectUrlRulesText """.stripMargin, consumerRedirectUrlJSON, consumerJSON, - List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError), List(apiTagConsumer), Some(List(canUpdateConsumerRedirectUrl)), http4sPartialFunction = Some(updateConsumerRedirectURL) @@ -3059,6 +3084,7 @@ object Http4s510 { (js, appType) } (postedJson, appType) = tup + _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(postedJson.redirect_url, Some(cc)) (consumer, _) <- createConsumerNewStyle( key = Some(Helpers.randomString(40).toLowerCase), secret = Some(Helpers.randomString(40).toLowerCase), @@ -3085,11 +3111,13 @@ object Http4s510 { "Create a Consumer", s"""Create a Consumer (Authenticated access). | + |$redirectUrlRulesText + | |$platformAppConsumerText |""", createConsumerRequestJsonV510, consumerJsonV510, - List(AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError), List(apiTagConsumer), None, http4sPartialFunction = Some(createMyConsumer) @@ -3148,6 +3176,8 @@ object Http4s510 { postedJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(JwtUtil.getSignedPayloadAsJson(postedJwt.jwt).getOrElse("{}")).extract[ConsumerPostJsonV510] } + // TPPs register their own domains, so this instance's redirect URL host list does not apply here. + _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(postedJson.redirect_url.getOrElse(""), Some(cc), applyHostList = false) certificateInfo: CertificateInfoJsonV510 <- Future(X509.getCertificateInfo(pem)) .map(unboxFullOrFail(_, Some(cc), X509GeneralError)) _ <- Helper.booleanToFuture(RegulatedEntityNotFoundByCertificate, 400, Some(cc)) { @@ -3268,7 +3298,7 @@ object Http4s510 { |""", ConsumerJwtPostJsonV510("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJkZXNjcmlwdGlvbiI6IlRQUCBkZXNjcmlwdGlvbiJ9.c5gPPsyUmnVW774y7h2xyLXg0wdtu25nbU2AvOmyzcWa7JTdCKuuy3CblxueGwqYkQDDQIya1Qny4blyAvh_a1Q28LgzEKBcH7Em9FZXerhkvR9v4FWbCC5AgNLdQ7sR8-rUQdShmJcGDKdVmsZjuO4XhY2Zx0nFnkcvYfsU9bccoAvkKpVJATXzwBqdoEOuFlplnbxsMH1wWbAd3hbcPPWTdvO43xavNZTB5ybgrXVDEYjw8D-98_ZkqxS0vfvhJ4cGefHViaFzp6zXm7msdBpcE__O9rFbdl9Gvup_bsMbrHJioIrmc2d15Yc-tTNTF9J4qjD_lNxMRlx5o2TZEw"), consumerJsonV510, - List(InvalidJsonFormat, UnknownError), + List(InvalidJsonFormat, InvalidRedirectUrl, UnknownError), List(apiTagDirectory, apiTagConsumer), Some(Nil), http4sPartialFunction = Some(createConsumerDynamicRegistration) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 722f8c3093..b25db23e42 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -2110,8 +2110,8 @@ object Http4s600 { } } } yield { - val redirectUris = Option(consumer.redirectURL.get).filter(_.nonEmpty) - .map(_.split("[,\\s]+").map(_.trim).filter(_.nonEmpty).toList).getOrElse(List.empty) + // Stored entries that fail the redirect URL rules (written before they existed) are never handed out. + val redirectUris = code.api.util.RedirectUrlValidation.validEntries(consumer.redirectURL.get) GetOidcClientResponseJsonV600( client_id = clientId, client_name = consumer.name.get, consumer_id = consumer.consumerId.get, @@ -2133,8 +2133,7 @@ object Http4s600 { } yield { consumerBox match { case Full(consumer) if consumer.isActive.get && consumer.secret.get == postedData.client_secret => - val redirectUris = Option(consumer.redirectURL.get).filter(_.nonEmpty) - .map(_.split("[,\\s]+").map(_.trim).filter(_.nonEmpty).toList) + val redirectUris = Some(code.api.util.RedirectUrlValidation.validEntries(consumer.redirectURL.get)).filter(_.nonEmpty) VerifyOidcClientResponseJsonV600( valid = true, client_id = Some(postedData.client_id), @@ -2394,6 +2393,18 @@ object Http4s600 { s"$InvalidJsonFormat The `Type` field can only accept the following field: " + AttributeTypeDocs.typesWithExamples + /** + * This returns the attribute COUNTERPARTY_ATTRIBUTE_ID only if it belongs to the counterparty in the URL. + * The middleware has already checked that the counterparty belongs to the bank and account in the URL + * (COUNTERPARTY_ID); an attribute is found by its own id, so it could belong to any counterparty. + */ + private def counterpartyAttributeOfCounterparty(counterpartyIdStr: String, + attributeId: String): Future[com.openbankproject.commons.model.CounterpartyAttributeTrait] = + for { + attributeBox <- code.counterpartyattribute.CounterpartyAttributeX.counterpartyAttributeProvider.vend.getCounterpartyAttributeById(attributeId) + } yield unboxFullOrFail( + attributeBox.filter(_.counterpartyId.value == counterpartyIdStr), None, CounterpartyAttributeNotFound, 404) + // POST /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes (201) lazy val createCounterpartyAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / counterpartyId / "attributes" => @@ -2421,18 +2432,21 @@ object Http4s600 { // DELETE /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID lazy val deleteCounterpartyAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / _ / "attributes" / attributeId => + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / counterpartyId / "attributes" / attributeId => EndpointHelpers.executeDelete(req) { cc => - code.api.util.newstyle.CounterpartyAttributeNewStyle.deleteCounterpartyAttribute(attributeId, Some(cc)) + for { + _ <- counterpartyAttributeOfCounterparty(counterpartyId, attributeId) + deleted <- code.api.util.newstyle.CounterpartyAttributeNewStyle.deleteCounterpartyAttribute(attributeId, Some(cc)) + } yield deleted } } // GET /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID lazy val getCounterpartyAttributeById: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / _ / "attributes" / attributeId => + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / counterpartyId / "attributes" / attributeId => EndpointHelpers.withUser(req) { (_, cc) => for { - (attribute, _) <- code.api.util.newstyle.CounterpartyAttributeNewStyle.getCounterpartyAttributeById(attributeId, Some(cc)) + attribute <- counterpartyAttributeOfCounterparty(counterpartyId, attributeId) } yield JSONFactory600.createCounterpartyAttributeJson(attribute) } } @@ -2454,6 +2468,7 @@ object Http4s600 { EndpointHelpers.executeAndRespond(req) { implicit cc => val rawBody = cc.httpBody.getOrElse("") for { + _ <- counterpartyAttributeOfCounterparty(counterpartyId, attributeId) postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the CounterpartyAttributeRequestJsonV600", 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CounterpartyAttributeRequestJsonV600] } @@ -9558,6 +9573,10 @@ object Http4s600 { |Returns client information including name, consumer_id, redirect_uris, and enabled status. |This endpoint does not verify the client secret - use POST /oidc/clients/verify for authentication. | + |redirect_uris lists only the Consumer's redirect URLs that meet the redirect URL rules (https; http only for localhost, + |127.0.0.1 or [::1]; or an app scheme in reverse-domain form; no wildcard, user information or fragment). A stored entry + |that fails them is left out, so an OIDC provider never redirects to it. See ${Glossary.getGlossaryItemLink("Redirect URL")} + |${userAuthenticationMessage(true)} |""", EmptyBody, @@ -9585,6 +9604,10 @@ object Http4s600 { |Returns `valid: true` if the client_id and client_secret match an active consumer. |Also returns the consumer_id and redirect_uris for use by the OIDC provider. | + |redirect_uris lists only the Consumer's redirect URLs that meet the redirect URL rules (https; http only for localhost, + |127.0.0.1 or [::1]; or an app scheme in reverse-domain form; no wildcard, user information or fragment). A stored entry + |that fails them is left out, so an OIDC provider never redirects to it. See ${Glossary.getGlossaryItemLink("Redirect URL")} + |${userAuthenticationMessage(true)} |""", VerifyOidcClientRequestJsonV600( @@ -9972,7 +9995,7 @@ object Http4s600 { implementedInApiVersion, nameOf(createCounterpartyAttribute), "POST", - "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes", "Create Counterparty Attribute", s""" | Create a new Counterparty Attribute for a given COUNTERPARTY_ID. @@ -9983,7 +10006,7 @@ object Http4s600 { """.stripMargin, counterpartyAttributeRequestJsonV600, counterpartyAttributeResponseJsonV600, - List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List($AuthenticatedUserIsRequired, InvalidJsonFormat, $CounterpartyNotFoundByCounterpartyId, UnknownError), List(apiTagCounterpartyAttribute, apiTagApi), Some(canCreateCounterpartyAttribute :: Nil), http4sPartialFunction = Some(createCounterpartyAttribute) @@ -9992,7 +10015,7 @@ object Http4s600 { implementedInApiVersion, nameOf(deleteCounterpartyAttribute), "DELETE", - "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes/COUNTERPARTY_ATTRIBUTE_ID", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID", "Delete Counterparty Attribute", s""" | Delete a Counterparty Attribute specified by COUNTERPARTY_ATTRIBUTE_ID. @@ -10002,7 +10025,7 @@ object Http4s600 { """.stripMargin, EmptyBody, EmptyBody, - List($AuthenticatedUserIsRequired, UnknownError), + List($AuthenticatedUserIsRequired, $CounterpartyNotFoundByCounterpartyId, CounterpartyAttributeNotFound, UnknownError), List(apiTagCounterpartyAttribute, apiTagApi), Some(canDeleteCounterpartyAttribute :: Nil), http4sPartialFunction = Some(deleteCounterpartyAttribute) @@ -10011,7 +10034,7 @@ object Http4s600 { implementedInApiVersion, nameOf(getCounterpartyAttributeById), "GET", - "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes/COUNTERPARTY_ATTRIBUTE_ID", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID", "Get Counterparty Attribute By ID", s""" | Get a specific Counterparty Attribute by its COUNTERPARTY_ATTRIBUTE_ID. @@ -10021,7 +10044,7 @@ object Http4s600 { """.stripMargin, EmptyBody, counterpartyAttributeResponseJsonV600, - List($AuthenticatedUserIsRequired, UnknownError), + List($AuthenticatedUserIsRequired, $CounterpartyNotFoundByCounterpartyId, CounterpartyAttributeNotFound, UnknownError), List(apiTagCounterpartyAttribute, apiTagApi), Some(canGetCounterpartyAttribute :: Nil), http4sPartialFunction = Some(getCounterpartyAttributeById) @@ -10030,7 +10053,7 @@ object Http4s600 { implementedInApiVersion, nameOf(getAllCounterpartyAttributes), "GET", - "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes", "Get All Counterparty Attributes", s""" | Get all attributes for the specified Counterparty. @@ -10040,7 +10063,7 @@ object Http4s600 { """.stripMargin, EmptyBody, counterpartyAttributesJsonV600, - List($AuthenticatedUserIsRequired, UnknownError), + List($AuthenticatedUserIsRequired, $CounterpartyNotFoundByCounterpartyId, UnknownError), List(apiTagCounterpartyAttribute, apiTagApi), Some(canGetCounterpartyAttributes :: Nil), http4sPartialFunction = Some(getAllCounterpartyAttributes) @@ -10049,7 +10072,7 @@ object Http4s600 { implementedInApiVersion, nameOf(updateCounterpartyAttribute), "PUT", - "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes/COUNTERPARTY_ATTRIBUTE_ID", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID", "Update Counterparty Attribute", s""" | Update an existing Counterparty Attribute specified by COUNTERPARTY_ATTRIBUTE_ID. @@ -10059,7 +10082,7 @@ object Http4s600 { """.stripMargin, counterpartyAttributeRequestJsonV600, counterpartyAttributeResponseJsonV600, - List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List($AuthenticatedUserIsRequired, InvalidJsonFormat, $CounterpartyNotFoundByCounterpartyId, CounterpartyAttributeNotFound, UnknownError), List(apiTagCounterpartyAttribute, apiTagApi), Some(canUpdateCounterpartyAttribute :: Nil), http4sPartialFunction = Some(updateCounterpartyAttribute) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index acb76f3992..99f90e0355 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -1624,7 +1624,9 @@ object LocalMappedConnector extends Connector with MdcLoggable { } override def getCounterpartyTrait(bankId: BankId, accountId: AccountId, counterpartyId: String, callContext: Option[CallContext]): OBPReturnType[Box[CounterpartyTrait]] = { - getCounterpartyByCounterpartyId(CounterpartyId(counterpartyId), callContext) + getCounterpartyByCounterpartyId(CounterpartyId(counterpartyId), callContext).map { case (counterparty, callContext) => + (counterparty.filter(found => found.thisBankId == bankId.value && found.thisAccountId == accountId.value), callContext) + } } override def getCounterpartyByCounterpartyId(counterpartyId: CounterpartyId, callContext: Option[CallContext]): OBPReturnType[Box[CounterpartyTrait]] = Future { @@ -5550,6 +5552,31 @@ object LocalMappedConnector extends Connector with MdcLoggable { for { (fromCounterparty, callContext) <- NewStyle.function.getCounterpartyByIbanAndBankAccountId(fromCounterpartyIban, toAccount.bankId, toAccount.accountId, callContext) (fromAccount, callContext) <- NewStyle.function.getBankAccountFromCounterparty(fromCounterparty, false, callContext) + // Here the caller is the party being refunded, so the money comes from the other party. + // The caller was authorised only on their own account, so the other party may not be an + // account on this instance: OBP-API can't debit an account the caller has no access to. + // The other party is an external account, and the payment goes through an adapter + // (for example a SEPA recall that the other bank has accepted). + (otherPartyAccountOnThisInstance, callContext) <- checkBankAccountExists(fromAccount.bankId, fromAccount.accountId, callContext) + _ <- Helper.booleanToFuture( + s"$InsufficientAuthorisationToCreateTransactionRequest A REFUND can't debit the account BANK_ID(${fromAccount.bankId.value}) ACCOUNT_ID(${fromAccount.accountId.value}), because the caller is not authorised on it.", + cc = callContext) { + otherPartyAccountOnThisInstance.isEmpty + } + // Money from an external account can only arrive through an adapter that really collects + // it (the SEPA adapter routes makePaymentv210 to itself). When this connector would make + // the payment, it would credit the account from a stand-in for the external account with + // no money behind it, so the refund is refused. + paymentConnectorName = code.api.Constant.CONNECTOR.openOr("mapped") match { + case "star" => code.bankconnectors.getConnectorNameAndMethodRouting( + "makePaymentv210", Array[(String, AnyRef)]("bankId" -> toAccount.bankId.value))._2 + case connectorName => connectorName + } + _ <- Helper.booleanToFuture( + s"$InvalidTransactionRequestType A REFUND that takes money from an external account needs an adapter that makes the payment (makePaymentv210); on this instance it would be made by the $paymentConnectorName connector.", + cc = callContext) { + paymentConnectorName != "mapped" + } } yield (fromAccount, toAccount, callContext) } } diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index dcc45a9ecb..e1d4b272fc 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -816,7 +816,7 @@ object LocalMappedConnectorInternal extends MdcLoggable { case Some(refundRequestTo) if refundRequestTo.counterparty_id.isDefined => val toCounterpartyId = CounterpartyId(refundRequestTo.counterparty_id.get) for { - (toCounterparty, callContext) <- NewStyle.function.getCounterpartyByCounterpartyId(toCounterpartyId, callContext) + (toCounterparty, callContext) <- NewStyle.function.getCounterpartyTrait(fromAccount.bankId, fromAccount.accountId, toCounterpartyId.value, callContext) (toAccount, callContext) <- NewStyle.function.getBankAccountFromCounterparty(toCounterparty, isOutgoingAccount = true, callContext) _ <- Helper.booleanToFuture(s"$CounterpartyBeneficiaryPermit", cc=callContext) { toCounterparty.isBeneficiary @@ -828,7 +828,7 @@ object LocalMappedConnectorInternal extends MdcLoggable { val fromCounterpartyId = CounterpartyId(transactionRequestBodyRefundJson.from.get.counterparty_id) val toAccount = fromAccount for { - (fromCounterparty, callContext) <- NewStyle.function.getCounterpartyByCounterpartyId(fromCounterpartyId, callContext) + (fromCounterparty, callContext) <- NewStyle.function.getCounterpartyTrait(toAccount.bankId, toAccount.accountId, fromCounterpartyId.value, callContext) (fromAccount, callContext) <- NewStyle.function.getBankAccountFromCounterparty(fromCounterparty, isOutgoingAccount = false, callContext) _ <- Helper.booleanToFuture(s"$CounterpartyBeneficiaryPermit", cc=callContext) { fromCounterparty.isBeneficiary @@ -844,10 +844,48 @@ object LocalMappedConnectorInternal extends MdcLoggable { _ <- Helper.booleanToFuture(s"${RefundedTransaction} Current input amount is: '${transDetailsJson.value.amount}'. It can not be more than the original amount(${(transaction.amount).abs})", cc=callContext) { (transaction.amount).abs >= transactionAmountNumber } - //TODO, we need additional field to guarantee the transaction is refunded... - // _ <- Helper.booleanToFuture(s"${RefundedTransaction}") { - // !((transaction.description.toString contains(" Refund to ")) && (transaction.description.toString contains(" and transaction_id("))) - // } + // A refund must be for a payment between the account in the URL and the party named in the + // request. With `to`, the caller asks for money they paid out to be sent back, so the original + // transaction is a debit on their account. With `from`, the caller pays back money they + // received, so it is a credit. Either way its other party has to be the named party. + requestsMoneyBack = transactionRequestBodyRefundJson.to.isDefined + otherParty = if (requestsMoneyBack) toAccount else fromAccount + (otherPartyOnThisInstance, callContext) <- Connector.connector.vend.checkBankAccountExists(otherParty.bankId, otherParty.accountId, callContext) + _ <- Helper.booleanToFuture(s"$RefundTransactionNotWithThisParty Current TRANSACTION_ID(${transactionId.value})", cc=callContext) { + val wasPaidInTheRightDirection = if (requestsMoneyBack) transaction.amount < 0 else transaction.amount > 0 + // The transaction records one routing of its other party (scheme and address) and that party's + // bank routing. The routing must be one of the named party's routings. Only an IBAN identifies + // an account on its own; any other routing (an account id, an account number) is unique only + // within its bank, so the bank must match too, and for an account on this instance an empty + // bank routing can't tell banks apart, so it is refused. + val originalScheme = transaction.otherAccount.otherAccountRoutingScheme.trim.toUpperCase + val originalAddress = transaction.otherAccount.otherAccountRoutingAddress.map(_.trim).getOrElse("") + val originalBank = transaction.otherAccount.otherBankRoutingAddress.map(_.trim).getOrElse("") + val otherPartyBank = otherParty.bankRoutingAddress.trim + val sameAccountRouting = originalAddress.nonEmpty && otherParty.accountRoutings.exists(routing => + routing.scheme.trim.toUpperCase == originalScheme && routing.address.trim == originalAddress) + val sameBank = + if (originalScheme == AccountRoutingScheme.IBAN.toString) true + else if (otherPartyOnThisInstance.isDefined) originalBank.nonEmpty && originalBank == otherPartyBank + else originalBank == otherPartyBank + wasPaidInTheRightDirection && sameAccountRouting && sameBank + } + + // A transaction can be refunded only once. Each refund records the transaction it refunds in its + // `original_transaction_id` attribute; a refund that was rejected, failed or cancelled doesn't count. + earlierRefundIds <- Future { + code.transactionRequestAttribute.TransactionRequestAttribute.findAll( + By(code.transactionRequestAttribute.TransactionRequestAttribute.BankId, bankId.value), + By(code.transactionRequestAttribute.TransactionRequestAttribute.Name, "original_transaction_id"), + By(code.transactionRequestAttribute.TransactionRequestAttribute.`Value`, transactionId.value) + ).map(_.transactionRequestId) + } + _ <- Helper.booleanToFuture(s"$RefundedTransaction Current TRANSACTION_ID(${transactionId.value})", cc=callContext) { + val endedWithoutPaying = Set(TransactionRequestStatus.REJECTED, TransactionRequestStatus.FAILED, TransactionRequestStatus.CANCELLED).map(_.toString) + earlierRefundIds + .flatMap(id => Connector.connector.vend.getTransactionRequestImpl(id, callContext).map(_._1).toList) + .forall(earlierRefund => endedWithoutPaying.contains(earlierRefund.status)) + } //we add the extra info (counterparty name + transaction_id) for this special Refund endpoint. newDescription = s"${transactionRequestBodyRefundJson.description} - Refund for transaction_id: (${transactionId.value}) to ${transaction.otherAccount.counterpartyName}" @@ -963,7 +1001,7 @@ object LocalMappedConnectorInternal extends MdcLoggable { } toCounterpartyId = transactionRequestBodyCounterparty.to.counterparty_id _ <- Future { logger.debug(s"After extracting counterparty id: $toCounterpartyId") } - (toCounterparty, callContext) <- NewStyle.function.getCounterpartyByCounterpartyId(CounterpartyId(toCounterpartyId), callContext) + (toCounterparty, callContext) <- NewStyle.function.getCounterpartyTrait(fromAccount.bankId, fromAccount.accountId, toCounterpartyId, callContext) transactionRequestAttributes <- if(transactionRequestBodyCounterparty.attributes.isDefined && transactionRequestBodyCounterparty.attributes.head.length > 0 ) { diff --git a/obp-api/src/main/scala/code/model/BankingData.scala b/obp-api/src/main/scala/code/model/BankingData.scala index cec96a5e94..b6b1e7b4f4 100644 --- a/obp-api/src/main/scala/code/model/BankingData.scala +++ b/obp-api/src/main/scala/code/model/BankingData.scala @@ -432,7 +432,11 @@ case class BankAccountExtended(val bankAccount: BankAccount) extends MdcLoggable APIUtil.hasAccountAccess(view, bankIdAccountId, user, callContext) match { case Full(true) => for{ - (counterparty, callContext) <- Connector.connector.vend.getCounterpartyByCounterpartyId(CounterpartyId(counterpartyID), callContext) + (counterpartyFoundById, callContext) <- Connector.connector.vend.getCounterpartyByCounterpartyId(CounterpartyId(counterpartyID), callContext) + // The lookup by id searches every account, but the caller's access was checked for this account only, + // so a counterparty that belongs to another account is treated as not found. + counterparty = counterpartyFoundById.filter(found => + found.thisBankId == bankIdAccountId.bankId.value && found.thisAccountId == bankIdAccountId.accountId.value) moderateOtherAccount <- if(counterparty.isDefined) { Future{ counterparty.map(BankAccountX.toInternalCounterparty).flatten.map(view.moderateOtherAccount).flatten diff --git a/obp-api/src/main/scala/code/model/OAuth.scala b/obp-api/src/main/scala/code/model/OAuth.scala index 6bcf38ac45..fd19f32b09 100644 --- a/obp-api/src/main/scala/code/model/OAuth.scala +++ b/obp-api/src/main/scala/code/model/OAuth.scala @@ -25,7 +25,7 @@ TESOBE (http://www.tesobe.com/) */ package code.model -import code.api.util.CommonFunctions.validUri +import code.api.util.CommonFunctions.{validRedirectUrls, validUri} import code.api.util.migration.Migration.DbFunction import code.api.util._ import code.consumer.{Consumers, ConsumersProvider} @@ -279,7 +279,10 @@ object MappedConsumersProvider extends ConsumersProvider with MdcLoggable { case None => } redirectURL match { - case Some(v) => c.redirectURL(v) + case Some(v) => + // updateConsumer does not run c.validate, so the redirect URL rules are applied here. + RedirectUrlValidation.consumerRedirectUrlError(v, applyHostList = false).foreach(error => throw new Error(error)) + c.redirectURL(v) case None => } logoURL match { @@ -612,7 +615,7 @@ class Consumer extends LongKeyedMapper[Consumer] with CreatedUpdated{ } object redirectURL extends MappedString(this, 250){ override def displayName = "Redirect URL:" - override def validations = validUri(this) _ :: super.validations + override def validations = validRedirectUrls(this) _ :: super.validations } object logoUrl extends MappedString(this, 250){ @@ -658,21 +661,6 @@ class Consumer extends LongKeyedMapper[Consumer] with CreatedUpdated{ object Consumer extends Consumer with MdcLoggable with LongKeyedMetaMapper[Consumer] { override def dbIndexes = UniqueIndex(key) :: UniqueIndex(azp, sub) :: super.dbIndexes - - def getRedirectURLByConsumerKey(consumerKey: String): String = { - logger.debug("hello from getRedirectURLByConsumerKey") - val consumer: Consumer = Consumers.consumers.vend.getConsumerByConsumerKey(consumerKey).openOrThrowException(s"OBP Consumer not found by consumerKey. You looked for $consumerKey Please check the database") - logger.debug(s"getRedirectURLByConsumerKey found consumer with id: ${consumer.id}, name is: ${consumer.name}, isActive is ${consumer.isActive}") - consumer.redirectURL.toString() - } - - /** - * match the flow style, it can be http, https, or Private-Use URI Scheme Redirection for app: - * http://some.domain.com/path - * https://some.domain.com/path - * com.example.app:/oauth2redirect/example-provider - */ - val redirectURLRegex = """^([.\w]+:|(http|https):/)/(www.)?\S+?(:\d{2,6})?\S*$""".r } object MappedNonceProvider extends NoncesProvider { diff --git a/obp-api/src/main/scala/code/util/Helper.scala b/obp-api/src/main/scala/code/util/Helper.scala index 9d149685ce..b24f696721 100644 --- a/obp-api/src/main/scala/code/util/Helper.scala +++ b/obp-api/src/main/scala/code/util/Helper.scala @@ -30,7 +30,7 @@ package code.util import org.json4s._ import code.api.cache.{Redis, RedisLogger} -import java.net.{Socket, SocketException, URL} +import java.net.{Socket, SocketException} import java.util.UUID.randomUUID import java.util.Date import code.api.util.{APIUtil, CallContext, CallContextLight, CustomJsonFormats} @@ -41,7 +41,6 @@ import code.model.dataAccess.internalMapping.MappedAccountIdMappingProvider import code.transaction.internalMapping.MappedTransactionIdMappingProvider import net.liftweb.common._ import org.json4s.Extraction._ -import org.apache.commons.lang3.StringUtils import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.{AccountBalance, AccountBalances, AccountHeld, AccountId, CoreAccount, Customer, CustomerId, Transaction, TransactionCore, TransactionId} import com.openbankproject.commons.util.{ReflectUtils, RequiredFieldValidation, RequiredInfo} @@ -198,50 +197,6 @@ object Helper extends Loggable { } - /** - * @param redirectUrl eg: http://localhost:8082/callback?foo=bar - * @return http://localhost:8082/callback - */ - def getStaticPortionOfRedirectURL(redirectUrl: String): Box[String] = { - tryo(redirectUrl.split("\\?")(0)) //return everything before the "?" - } - - /** - * extract the host-only portion of a redirect URL. - * - * @param redirectUrl -> http://localhost:8082/callback?foo=bar - * @return hostOnlyOfRedirectURL -> http://localhost:8082 - */ - @deprecated("We can not only use hostname as the redirectUrl, now add new method `getStaticPortionOfRedirectURL` ","05.12.2023") - def getHostOnlyOfRedirectURL(redirectUrl: String): Box[String] = { - val url = new URL(redirectUrl) - val protocol = url.getProtocol() // http - val authority = url.getAuthority()// localhost:8082, this will contain the port. - tryo(s"$protocol://$authority") // http://localhost:8082 - } - - /** - * check the redirect url is valid with default values. - */ - def isValidInternalRedirectUrl(url: String) : Boolean = { - val internalRedirectUrlsWhiteList = List( - "/", - "/dummy-user-tokens","/create-sandbox-account", - "/add-user-auth-context-update-request","/otp", - "/terms-and-conditions", "/privacy-policy", - "/confirm-bg-consent-request", - "/confirm-bg-consent-request-sca", - "/confirm-vrp-consent-request", - "/confirm-vrp-consent", - "/consent-screen", - "/consent", - ) - - val extractCleanURL = StringUtils.substringBefore(url, "?") - - internalRedirectUrlsWhiteList.contains(extractCleanURL) - } - /** * Used for version extraction from props string */ diff --git a/obp-api/src/test/scala/code/api/berlin/group/signing/PSD2RequestSigner.scala b/obp-api/src/test/scala/code/api/berlin/group/signing/PSD2RequestSigner.scala index edd61cc788..91b304d001 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/signing/PSD2RequestSigner.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/signing/PSD2RequestSigner.scala @@ -69,7 +69,7 @@ class PSD2RequestSigner( * @param psuDeviceId Optional PSU device ID (default: "device-1234567890") * @param psuDeviceName Optional PSU device name (default: "Kalina-PC") * @param psuIpAddress Optional PSU IP address (default: "psu-service.local") - * @param tppRedirectUri Optional TPP redirect URI (default: "tppapp://example.com/redirect") + * @param tppRedirectUri Optional TPP redirect URI (default: "com.example.tppapp://redirect", an app scheme in reverse-domain form) * @param tppNokRedirectUri Optional TPP error redirect URI (default: "https://example.com/redirect") * @return Map of HTTP headers for the signed request */ @@ -78,7 +78,7 @@ class PSD2RequestSigner( psuDeviceId: String = "device-1234567890", psuDeviceName: String = "Kalina-PC", psuIpAddress: String = "psu-service.local", // Use DNS/hostname instead of raw IP - tppRedirectUri: String = "tppapp://example.com/redirect", + tppRedirectUri: String = "com.example.tppapp://redirect", tppNokRedirectUri: String = "https://example.com/redirect" ): Map[String, String] = { @@ -180,7 +180,7 @@ trait PSD2SigningSupport { psuDeviceId: String, psuDeviceName: String, psuIpAddress: String, - tppRedirectUri: String = "tppapp://example.com/redirect", + tppRedirectUri: String = "com.example.tppapp://redirect", tppNokRedirectUri: String = "https://example.com/redirect" ): Map[String, String] = { psd2Signer.signRequest(requestBody, psuDeviceId, psuDeviceName, psuIpAddress, tppRedirectUri, tppNokRedirectUri) diff --git a/obp-api/src/test/scala/code/api/util/BerlinGroupMandatoryHeadersTest.scala b/obp-api/src/test/scala/code/api/util/BerlinGroupMandatoryHeadersTest.scala index f209fc2ff7..246b91ecd7 100644 --- a/obp-api/src/test/scala/code/api/util/BerlinGroupMandatoryHeadersTest.scala +++ b/obp-api/src/test/scala/code/api/util/BerlinGroupMandatoryHeadersTest.scala @@ -182,6 +182,78 @@ class BerlinGroupMandatoryHeadersTest extends BerlinGroupServerSetupV1_3 { } } + // ─── TPP redirect URI rules ────────────────────────────────────────────── + + feature("BG TPP redirect URIs must meet the redirect URL rules") { + + scenario("A javascript: TPP-Redirect-URI is rejected", MandatoryHeaders) { + val result = callValidate(bgConsentUrl, verb = "POST", headers = Map( + "X-Request-ID" -> UUID.randomUUID().toString, + "TPP-Redirect-URI" -> "javascript:alert(1)" + )) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include(ErrorMessages.InvalidRedirectUrl) + result.asInstanceOf[Failure].msg should include("TPP-Redirect-URI") + } + + scenario("A plain-http public TPP-Nok-Redirect-URI is rejected, on any Berlin Group request", MandatoryHeaders) { + val result = callValidate(bgUrl, headers = Map( + "X-Request-ID" -> UUID.randomUUID().toString, + "TPP-Nok-Redirect-URI" -> "http://tpp.example.com/nok" + )) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include(ErrorMessages.InvalidRedirectUrl) + result.asInstanceOf[Failure].msg should include("TPP-Nok-Redirect-URI") + } + + scenario("https and reverse-domain app scheme TPP redirect URIs are accepted", MandatoryHeaders) { + val result = callValidate(bgConsentUrl, verb = "POST", headers = Map( + "X-Request-ID" -> UUID.randomUUID().toString, + "TPP-Redirect-URI" -> "com.example.tppapp://redirect", + "TPP-Nok-Redirect-URI" -> "https://tpp.example.com/nok" + )) + result should not be a[Failure] + } + } + + feature("BG TPP redirect URIs must be signed when berlin_group_require_signed_tpp_redirect_uri is true") { + + scenario("A TPP-Redirect-URI that the signature does not cover is rejected", MandatoryHeaders) { + setPropsValues("berlin_group_require_signed_tpp_redirect_uri" -> "true") + val result = callValidate(bgConsentUrl, verb = "POST", headers = Map( + "X-Request-ID" -> UUID.randomUUID().toString, + "TPP-Redirect-URI" -> "https://tpp.example.com/redirect", + "Signature" -> """keyId="CA=CN=Test CA, SN=43A, O=Test", algorithm="rsa-sha256", headers="digest date x-request-id", signature="abc123=="""" + )) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include(ErrorMessages.InvalidSignatureHeader) + result.asInstanceOf[Failure].msg should include("must cover TPP-Redirect-URI") + } + + scenario("A TPP-Redirect-URI that the signature covers passes this check", MandatoryHeaders) { + setPropsValues("berlin_group_require_signed_tpp_redirect_uri" -> "true") + val result = callValidate(bgConsentUrl, verb = "POST", headers = Map( + "X-Request-ID" -> UUID.randomUUID().toString, + "TPP-Redirect-URI" -> "https://tpp.example.com/redirect", + "Signature" -> """keyId="CA=CN=Test CA, SN=43A, O=Test", algorithm="rsa-sha256", headers="digest date x-request-id tpp-redirect-uri", signature="abc123=="""" + )) + // The signature itself is not verifiable here (no certificate), so a later check may still refuse it, + // but never for leaving the redirect header unsigned. + result match { + case failure: Failure => failure.msg should not include ("must cover") + case _ => + } + } + + scenario("An unsigned TPP-Redirect-URI is accepted when the prop is false", MandatoryHeaders) { + val result = callValidate(bgConsentUrl, verb = "POST", headers = Map( + "X-Request-ID" -> UUID.randomUUID().toString, + "TPP-Redirect-URI" -> "https://tpp.example.com/redirect" + )) + result should not be a[Failure] + } + } + // ─── Disabled check ─────────────────────────────────────────────────────── feature("BG mandatory headers - disabled when list is empty") { diff --git a/obp-api/src/test/scala/code/api/util/RedirectUrlValidationTest.scala b/obp-api/src/test/scala/code/api/util/RedirectUrlValidationTest.scala new file mode 100644 index 0000000000..224a2797fd --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/RedirectUrlValidationTest.scala @@ -0,0 +1,140 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, 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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.util + +import code.setup.PropsReset +import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers} + +/** + * This suite checks the redirect URL rules that every Consumer redirect URL must meet before it is stored. + */ +class RedirectUrlValidationTest extends FeatureSpec with Matchers with GivenWhenThen with PropsReset { + + feature("RedirectUrlValidation accepts the allowed forms") { + scenario("https, loopback http, reverse-domain app schemes, several entries and an empty value") { + List( + "https://a.com/cb", + "https://a.com:8443/cb?x=1", + "http://localhost:5173/cb", + "http://127.0.0.1:8080/cb", + "http://[::1]:8080/cb", + "com.example.app:/cb", + "x-com.tesobe.helloobp.ios://callback", + "https://a.com/cb https://b.com/cb", + "https://a.com/cb,http://localhost:3000/cb", + "", + " " + ).foreach { value => + withClue(value) { RedirectUrlValidation.firstProblem(value) shouldBe None } + } + } + } + + feature("RedirectUrlValidation rejects everything else") { + scenario("script schemes, wildcards, user information, fragments, public http and missing schemes") { + List( + "javascript:alert(1)", + "data:text/html;base64,PHNjcmlwdD4=", + "vbscript:x", + "file:///etc/passwd", + "https://a.com,javascript:x", + "https://*.a.com", + "https://user@evil.com", + "https://a.com/cb#fragment", + "http://example.com", + "myapp://callback", + "www.openbankproject.com", + "https:///cb" + ).foreach { value => + withClue(value) { RedirectUrlValidation.firstProblem(value) should not be None } + } + } + + scenario("the problem names the rejected entry") { + RedirectUrlValidation.firstProblem("https://a.com/cb javascript:alert(1)").get should startWith("'javascript:alert(1)'") + } + } + + feature("RedirectUrlValidation.validEntries") { + scenario("keeps only the allowed entries of a stored value") { + RedirectUrlValidation.validEntries("https://a.com/cb,javascript:x http://example.com http://localhost/cb") shouldBe + List("https://a.com/cb", "http://localhost/cb") + } + } + + feature("redirect_url_allowed_hosts") { + scenario("unset or empty, it puts no limit on hosts") { + setPropsValues("redirect_url_allowed_hosts" -> "") + RedirectUrlValidation.allowedHosts shouldBe Nil + RedirectUrlValidation.consumerRedirectUrlError("https://any-third-party.example/cb", applyHostList = true) shouldBe None + RedirectUrlValidation.allowedHostsDescription should include("any host") + } + + scenario("set, it allows listed hosts, hosts under a listed domain, app schemes and the API's own host") { + setPropsValues( + "redirect_url_allowed_hosts" -> "portal.example.com, .bank.example", + "hostname" -> "https://api.example.org" + ) + RedirectUrlValidation.allowedHosts should contain allOf ("portal.example.com", ".bank.example", "api.example.org") + List( + "https://portal.example.com/cb", + "https://PORTAL.example.com/cb", + "https://bank.example/cb", + "https://app.bank.example/cb", + "https://api.example.org/cb", + "com.example.app:/cb" + ).foreach { value => + withClue(value) { RedirectUrlValidation.consumerRedirectUrlError(value, applyHostList = true) shouldBe None } + } + RedirectUrlValidation.allowedHostsDescription should include("portal.example.com") + } + + scenario("set, it refuses other hosts with RedirectUrlHostNotAllowed") { + setPropsValues( + "redirect_url_allowed_hosts" -> "portal.example.com, .bank.example", + "hostname" -> "https://api.example.org" + ) + List( + "https://evil.example/cb", + "https://portal.example.com.evil.example/cb", + "https://notbank.example/cb", + "http://localhost:5173/cb" + ).foreach { value => + withClue(value) { + RedirectUrlValidation.consumerRedirectUrlError(value, applyHostList = true).get should startWith(ErrorMessages.RedirectUrlHostNotAllowed) + } + } + } + + scenario("set, it is not applied where applyHostList is false, and a rule break still wins") { + setPropsValues("redirect_url_allowed_hosts" -> "portal.example.com") + RedirectUrlValidation.consumerRedirectUrlError("https://tpp.example/cb", applyHostList = false) shouldBe None + RedirectUrlValidation.consumerRedirectUrlError("javascript:alert(1)", applyHostList = true).get should startWith(ErrorMessages.InvalidRedirectUrl) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v1_4_0/CrmEventsTest.scala b/obp-api/src/test/scala/code/api/v1_4_0/CrmEventsTest.scala new file mode 100644 index 0000000000..f634ef2044 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v1_4_0/CrmEventsTest.scala @@ -0,0 +1,109 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, 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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.v1_4_0 + +import java.util.Date + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanGetCrmEvents +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles} +import code.crm.MappedCrmEvent +import code.entitlement.Entitlement +import code.setup.{APIResponse, DefaultUsers} +import org.json4s._ + +/** + * This suite checks that getCrmEvents, which returns every CRM event at a bank for all customers, needs an + * authenticated User with the Role CanGetCrmEvents at that bank, and returns only that bank's events. + */ +class CrmEventsTest extends V140ServerSetup with DefaultUsers { + + override def beforeEach(): Unit = { + super.beforeEach() + MappedCrmEvent.bulkDelete_!!() + } + + override def afterEach(): Unit = { + super.afterEach() + MappedCrmEvent.bulkDelete_!!() + } + + private def crmEventsAt(bankId: String) = (v1_4Request / "banks" / bankId / "crm-events").GET + + private def messageOf(response: APIResponse): String = (response.body \ "message").extractOpt[String].getOrElse("") + + private def createCrmEvent(crmEventId: String, bankId: String, customerName: String) = + MappedCrmEvent.create + .mCrmEventId(crmEventId) + .mBankId(bankId) + .mUserId(resourceUser2) + .mCustomerName(customerName) + .mCustomerNumber("1234") + .mScheduledDate(new Date(12340000)) + .mActualDate(new Date(12340000)) + .mChannel("PHONE") + .mDetail("Call about mortgage") + .mResult("No answer") + .mCategory("Category X") + .saveMe() + + feature("getCrmEvents needs authentication and the Role CanGetCrmEvents") { + + scenario("an anonymous caller is refused with 401") { + createCrmEvent("CRM-EVENT-1", testBankId1.value, "Customer One") + val response = makeGetRequest(crmEventsAt(testBankId1.value)) + response.code should equal(401) + messageOf(response) should include(AuthenticatedUserIsRequired) + } + + scenario("a User without the Role is refused with 403") { + createCrmEvent("CRM-EVENT-1", testBankId1.value, "Customer One") + val response = makeGetRequest(crmEventsAt(testBankId1.value) <@ (user1)) + response.code should equal(403) + messageOf(response) should include(UserHasMissingRoles) + messageOf(response) should include(CanGetCrmEvents.toString) + } + + scenario("a User with the Role at another bank is refused with 403") { + Entitlement.entitlement.vend.addEntitlement(testBankId2.value, resourceUser1.userId, CanGetCrmEvents.toString) + val response = makeGetRequest(crmEventsAt(testBankId1.value) <@ (user1)) + response.code should equal(403) + } + + scenario("a User with the Role at the bank gets that bank's events only") { + createCrmEvent("CRM-EVENT-1", testBankId1.value, "Customer One") + createCrmEvent("CRM-EVENT-2", testBankId2.value, "Customer Two") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanGetCrmEvents.toString) + val response = makeGetRequest(crmEventsAt(testBankId1.value) <@ (user1)) + response.code should equal(200) + val events = (response.body \ "crm_events").children + events.map(event => (event \ "id").extract[String]) should equal(List("CRM-EVENT-1")) + (events.head \ "customer_name").extract[String] should equal("Customer One") + } + } +} diff --git a/obp-api/src/test/scala/code/api/v2_0_0/AccountTest.scala b/obp-api/src/test/scala/code/api/v2_0_0/AccountTest.scala index 84a9997dcb..54168aabe0 100644 --- a/obp-api/src/test/scala/code/api/v2_0_0/AccountTest.scala +++ b/obp-api/src/test/scala/code/api/v2_0_0/AccountTest.scala @@ -27,6 +27,8 @@ TESOBE (http://www.tesobe.com/) package code.api.v2_0_0 +import code.accountholders.AccountHolders +import com.openbankproject.commons.model.AccountId import org.json4s._ import code.api.util.APIUtil.OAuth._ import code.api.util.ErrorMessages @@ -177,4 +179,28 @@ class AccountTest extends V200ServerSetup with DefaultUsers with PrivateUser2Acc } } + private def createAccountBodyFor(userId: String) = + CreateAccountJSON(userId, "CURRENT", mockAccountLabel1, AmountOfMoneyJSON121("EUR", "0")) + + feature("Create Account refuses an ACCOUNT_ID that already exists - v2.0.0") { + + scenario("a second User PUTting an existing ACCOUNT_ID gets 409 AccountIdAlreadyExists and does not become a holder") { + val existingAccountId = "EXISTING_ACCOUNT_ID_V200" + val createdByUser1 = makePutRequest( + (v2_0Request / "banks" / testBankId1.value / "accounts" / existingAccountId).PUT <@ (user1), + write(createAccountBodyFor(resourceUser1.userId))) + createdByUser1.code should equal(200) + + val attemptByUser2 = makePutRequest( + (v2_0Request / "banks" / testBankId1.value / "accounts" / existingAccountId).PUT <@ (user2), + write(createAccountBodyFor(resourceUser2.userId))) + attemptByUser2.code should equal(409) + (attemptByUser2.body \ "message").extract[String] should include(ErrorMessages.AccountIdAlreadyExists) + + val holderUserIds = AccountHolders.accountHolders.vend + .getAccountHolders(testBankId1, AccountId(existingAccountId)).map(_.userId) + holderUserIds should contain(resourceUser1.userId) + holderUserIds should not contain(resourceUser2.userId) + } + } } diff --git a/obp-api/src/test/scala/code/api/v2_0_0/TransactionTypesIsPublicTest.scala b/obp-api/src/test/scala/code/api/v2_0_0/TransactionTypesIsPublicTest.scala new file mode 100644 index 0000000000..d47c98fbfa --- /dev/null +++ b/obp-api/src/test/scala/code/api/v2_0_0/TransactionTypesIsPublicTest.scala @@ -0,0 +1,67 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, 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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.v2_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ErrorMessages.AuthenticatedUserIsRequired +import code.setup.{APIResponse, DefaultUsers} +import org.json4s._ + +/** + * This suite checks that getTransactionTypes (GET /banks/BANK_ID/transaction-types) honours the prop + * apiOptions.getTransactionTypesIsPublic: anyone may call it while the prop is true (the default), and only a + * logged-in User may call it when the prop is false. + */ +class TransactionTypesIsPublicTest extends V200ServerSetup with DefaultUsers { + + private def transactionTypesAt(bankId: String) = (v2_0Request / "banks" / bankId / "transaction-types").GET + + private def messageOf(response: APIResponse): String = (response.body \ "message").extractOpt[String].getOrElse("") + + feature("getTransactionTypes honours apiOptions.getTransactionTypesIsPublic") { + + scenario("an anonymous caller gets the transaction types while the prop is true") { + setPropsValues("apiOptions.getTransactionTypesIsPublic" -> "true") + val response = makeGetRequest(transactionTypesAt(testBankId1.value)) + response.code should equal(200) + } + + scenario("an anonymous caller is refused with 401 when the prop is false") { + setPropsValues("apiOptions.getTransactionTypesIsPublic" -> "false") + val response = makeGetRequest(transactionTypesAt(testBankId1.value)) + response.code should equal(401) + messageOf(response) should include(AuthenticatedUserIsRequired) + } + + scenario("a logged-in User gets the transaction types when the prop is false") { + setPropsValues("apiOptions.getTransactionTypesIsPublic" -> "false") + val response = makeGetRequest(transactionTypesAt(testBankId1.value) <@ (user1)) + response.code should equal(200) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v2_1_0/UpdateConsumerRedirectUrlTest.scala b/obp-api/src/test/scala/code/api/v2_1_0/UpdateConsumerRedirectUrlTest.scala index 18659dc731..12db38cb23 100644 --- a/obp-api/src/test/scala/code/api/v2_1_0/UpdateConsumerRedirectUrlTest.scala +++ b/obp-api/src/test/scala/code/api/v2_1_0/UpdateConsumerRedirectUrlTest.scala @@ -68,6 +68,25 @@ class UpdateConsumerRedirectUrlTest extends V210ServerSetup with DefaultUsers { error should equal(UserHasMissingRoles + CanUpdateConsumerRedirectUrl) } + scenario("Try to Update Redirect Url without proper role when consumers are enabled by default") { + // The role used to be checked only when consumers_enabled_by_default was false, + // so on an instance with the prop set to true the creator could change it without the role. + setPropsValues("consumers_enabled_by_default" -> "true") + + When("We make the request Update Redirect Url for a Consumer") + val requestPut = (v2_1Request / "management" / "consumers" / testConsumer.id.get / "consumer" / "redirect_url" ).PUT <@ (user1) + val responsePut = makePutRequest(requestPut, write(consumerRedirectUrlJSON)) + + Then("We should get a 403") + responsePut.code should equal(403) + + val error = (responsePut.body \ "message" ) match { + case JString(i) => i + case _ => "" + } + error should equal(UserHasMissingRoles + CanUpdateConsumerRedirectUrl) + } + scenario("Try to Update Redirect Url created by other user ") { Then("We add entitlement to user2") diff --git a/obp-api/src/test/scala/code/api/v2_2_0/AccountTest.scala b/obp-api/src/test/scala/code/api/v2_2_0/AccountTest.scala index 62261c6395..3eb6046d92 100644 --- a/obp-api/src/test/scala/code/api/v2_2_0/AccountTest.scala +++ b/obp-api/src/test/scala/code/api/v2_2_0/AccountTest.scala @@ -27,6 +27,8 @@ TESOBE (http://www.tesobe.com/) package code.api.v2_2_0 +import code.accountholders.AccountHolders +import com.openbankproject.commons.model.AccountId import org.json4s._ import code.api.Constant import code.api.util.APIUtil.OAuth._ @@ -224,4 +226,34 @@ class AccountTest extends V220ServerSetup with DefaultUsers { } } + private def createAccountBodyFor(userId: String) = + CreateAccountJSONV220( + user_id = userId, + label = "Label", + `type` = "CURRENT", + balance = AmountOfMoneyJsonV121("EUR", "0"), + branch_id = "1234", + account_routing = AccountRoutingJsonV121(scheme = "OBP", address = "UK123456")) + + feature("Create Account refuses an ACCOUNT_ID that already exists - v2.2.0") { + + scenario("a second User PUTting an existing ACCOUNT_ID gets 409 AccountIdAlreadyExists and does not become a holder") { + val existingAccountId = "EXISTING_ACCOUNT_ID_V220" + val createdByUser1 = makePutRequest( + (v2_2Request / "banks" / testBankId1.value / "accounts" / existingAccountId).PUT <@ (user1), + write(createAccountBodyFor(resourceUser1.userId))) + createdByUser1.code should equal(200) + + val attemptByUser2 = makePutRequest( + (v2_2Request / "banks" / testBankId1.value / "accounts" / existingAccountId).PUT <@ (user2), + write(createAccountBodyFor(resourceUser2.userId))) + attemptByUser2.code should equal(409) + (attemptByUser2.body \ "message").extract[String] should include(ErrorMessages.AccountIdAlreadyExists) + + val holderUserIds = AccountHolders.accountHolders.vend + .getAccountHolders(testBankId1, AccountId(existingAccountId)).map(_.userId) + holderUserIds should contain(resourceUser1.userId) + holderUserIds should not contain(resourceUser2.userId) + } + } } diff --git a/obp-api/src/test/scala/code/api/v3_0_0/CounterpartyTest.scala b/obp-api/src/test/scala/code/api/v3_0_0/CounterpartyTest.scala index d395bfac12..4c6a35745f 100644 --- a/obp-api/src/test/scala/code/api/v3_0_0/CounterpartyTest.scala +++ b/obp-api/src/test/scala/code/api/v3_0_0/CounterpartyTest.scala @@ -31,6 +31,10 @@ import code.api.Constant._ import com.openbankproject.commons.util.ApiVersion import code.api.v3_0_0.Http4s300.Implementations3_0_0 import com.github.dwickern.macros.NameOf.nameOf +import code.api.util.ErrorMessages.CounterpartyNotFound +import code.metadata.counterparties.Counterparties +import code.api.v2_0_0.BasicAccountsJSON +import com.openbankproject.commons.model.{AccountId, BankId, ErrorMessage} import org.scalatest.Tag class CounterpartyTest extends V300ServerSetup { @@ -74,4 +78,41 @@ class CounterpartyTest extends V300ServerSetup { } } + feature("Get Other Account by Id only returns counterparties of the account in the URL - V300") { + scenario("A counterparty of the account in the URL is returned", VersionOfApi, ApiEndpoint2) { + Given("A counterparty that belongs to the account in the URL") + val bankId = randomBankId + val accountId = randomPrivateAccountId(bankId) + val counterparty = createCounterparty(bankId, accountId, accountId, true, resourceUser1.userId) + Counterparties.counterparties.vend.getOrCreateMetadata( + BankId(bankId), AccountId(accountId), counterparty.counterpartyId, counterparty.name) + + When("We ask for it through that account") + val response = getOtherAccountByIdForBankAccount(bankId, accountId, SYSTEM_OWNER_VIEW_ID, counterparty.counterpartyId, user1) + + Then("We should get a 200 with that counterparty") + response.code should equal(200) + response.body.extract[OtherAccountJsonV300].id should equal(counterparty.counterpartyId) + } + + scenario("A counterparty of a different account is not found", VersionOfApi, ApiEndpoint2) { + Given("A counterparty that belongs to a different account than the one in the URL") + val bankId = randomBankId + val accountIds = getPrivateAccounts(bankId, user1).body.extract[BasicAccountsJSON].accounts.map(_.id) + accountIds.size should be >= 2 + val accountInUrl = accountIds(0) + val otherAccount = accountIds(1) + val counterparty = createCounterparty(bankId, otherAccount, otherAccount, true, resourceUser1.userId) + Counterparties.counterparties.vend.getOrCreateMetadata( + BankId(bankId), AccountId(otherAccount), counterparty.counterpartyId, counterparty.name) + + When("We ask for it through the account in the URL") + val response = getOtherAccountByIdForBankAccount(bankId, accountInUrl, SYSTEM_OWNER_VIEW_ID, counterparty.counterpartyId, user1) + + Then("We should get a 400 CounterpartyNotFound, even though the caller can also see the other account") + response.code should equal(400) + response.body.extract[ErrorMessage].message should startWith(CounterpartyNotFound) + } + } + } diff --git a/obp-api/src/test/scala/code/api/v3_1_0/UserAuthContextUpdateTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/UserAuthContextUpdateTest.scala index 2e4664fac1..10c372edc1 100644 --- a/obp-api/src/test/scala/code/api/v3_1_0/UserAuthContextUpdateTest.scala +++ b/obp-api/src/test/scala/code/api/v3_1_0/UserAuthContextUpdateTest.scala @@ -36,6 +36,8 @@ import code.consumer.Consumers import code.context.UserAuthContextUpdateProvider import code.entitlement.Entitlement import code.scope.Scope +import code.usercustomerlinks.UserCustomerLink +import code.api.util.ErrorMessages import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.UserAuthContextUpdateStatus import com.openbankproject.commons.model.enums.StrongCustomerAuthentication @@ -110,6 +112,42 @@ class UserAuthContextUpdateTest extends V310ServerSetup { responseUserAuthContextUpdate310.code should equal(200) val status = responseUserAuthContextUpdate310.body.extract[UserAuthContextUpdateJson].status status should equal(UserAuthContextUpdateStatus.REJECTED.toString) + + And("A wrong answer must not link the User to the Customer") + UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser1.userId, infoPost.customer_id).isDefined should equal(false) + } + scenario("Another User cannot answer, or use up, the challenge of a User's update", ApiEndpoint1, ApiEndpoint2, VersionOfApi) { + val bankId = randomBankId + val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(user1.get._1.key).map(_.id.get.toString).getOrElse("") + Scope.scope.vend.addScope(bankId, consumerId, ApiRole.canCreateUserAuthContextUpdate.toString()) + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateCustomer.toString) + val response310 = makePostRequest((v3_1_0_Request / "banks" / bankId / "customers").POST <@(user1), write(postCustomerJson)) + response310.code should equal(201) + val infoPost = response310.body.extract[CustomerJsonV310] + + val scaMethod = StrongCustomerAuthentication.SMS.toString + val createResponse = makePostRequest( + (v3_1_0_Request / "banks" / bankId / "users" / "current" / "auth-context-updates" / scaMethod).POST <@(user1), + write(postUserAuthContextJson.copy(value = infoPost.customer_number))) + createResponse.code should equal(201) + val authContextUpdateId = createResponse.body.extract[UserAuthContextUpdateJson].user_auth_context_update_id + val challenge = UserAuthContextUpdateProvider.userAuthContextUpdateProvider.vend.getUserAuthContextUpdatesBox(resourceUser1.userId) + .toList.flatten.filter(_.userAuthContextUpdateId == authContextUpdateId).map(_.challenge).head + + When("User2 answers User1's update, even with the right answer") + val answerPath = v3_1_0_Request / "banks" / bankId / "users" / "current" / "auth-context-updates" / authContextUpdateId / "challenge" + val responseOfUser2 = makePostRequest(answerPath.POST <@(user2), write(PostUserAuthContextUpdateJsonV310(answer = challenge))) + Then("It is refused as not found, and nobody is linked to the Customer") + responseOfUser2.code should equal(400) + (responseOfUser2.body \ "message").extract[String] should include(ErrorMessages.UserAuthContextUpdateNotFound) + UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser2.userId, infoPost.customer_id).isDefined should equal(false) + UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser1.userId, infoPost.customer_id).isDefined should equal(false) + + And("User1 can still answer it, because the challenge was not used up") + val responseOfUser1 = makePostRequest(answerPath.POST <@(user1), write(PostUserAuthContextUpdateJsonV310(answer = challenge))) + responseOfUser1.code should equal(200) + responseOfUser1.body.extract[UserAuthContextUpdateJson].status should equal(UserAuthContextUpdateStatus.ACCEPTED.toString) + UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser1.userId, infoPost.customer_id).isDefined should equal(true) } scenario("We will call the Answer endpoint with user credentials and right challenge answer", ApiEndpoint1, ApiEndpoint2, VersionOfApi) { When("We try to answer the User Auth Context Update v3.1.0") @@ -150,7 +188,7 @@ class UserAuthContextUpdateTest extends V310ServerSetup { When("We try to make the GET request v3.1.0") Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetUserAuthContext.toString) - val successGetReq = (v3_1_0_Request / "users" / userId1.value / "auth-context").GET <@(user1) + val successGetReq = (v3_1_0_Request / "users" / resourceUser1.userId / "auth-context").GET <@(user1) val successGetRes = makeGetRequest(successGetReq) Then("We should get a 200") successGetRes.code should equal(200) diff --git a/obp-api/src/test/scala/code/api/v4_0_0/CounterpartyTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/CounterpartyTest.scala index 1b44715d72..461502c946 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/CounterpartyTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/CounterpartyTest.scala @@ -35,6 +35,7 @@ import code.api.util.ApiRole._ import code.api.util.ErrorMessages import code.api.v4_0_0.Http4s400.Implementations4_0_0 import code.entitlement.Entitlement +import code.metadata.counterparties.Counterparties import code.views.Views import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.{AccountId, ErrorMessage, ViewId, BankIdAccountIdViewId} @@ -417,7 +418,9 @@ class CounterpartyTest extends V400ServerSetup { } Then(s"we can test the $ApiEndpoint7") - val requestDelete = (v4_0_0_Request / "banks" / bankId.value / "accounts" / accountId.value / viewId.value / "counterparties" / "xxx" ).DELETE <@ (user1) + // A real counterparty of this account, so that the view's missing permission is what refuses the request. + val existingCounterpartyId = createCounterparty(bankId.value, accountId.value, accountId.value, true, resourceUser1.userId).counterpartyId + val requestDelete = (v4_0_0_Request / "banks" / bankId.value / "accounts" / accountId.value / viewId.value / "counterparties" / existingCounterpartyId ).DELETE <@ (user1) val responseDelete = makeDeleteRequest(requestDelete) responseDelete.code should equal(403) responseDelete.body.toString contains(s"${ErrorMessages.NoViewPermission}") @@ -427,4 +430,63 @@ class CounterpartyTest extends V400ServerSetup { } + feature("A counterparty is only reachable through its own account") { + + // A counterparty of testAccount0, addressed through testAccount1. user1 has the owner view on both, + // so only the rule that a counterparty belongs to one account can refuse these requests. + def counterpartyOfTestAccount0(): String = { + val counterparty = createCounterparty(testBankId1.value, testAccountId0.value, testAccountId0.value, true, resourceUser1.userId) + Counterparties.counterparties.vend.getOrCreateMetadata( + testBankId1, testAccountId0, counterparty.counterpartyId, counterparty.name) + counterparty.counterpartyId + } + def stillExists(counterpartyId: String): Boolean = + Counterparties.counterparties.vend.getCounterparty(counterpartyId).isDefined + val accountUrl = v4_0_0_Request / "banks" / testBankId1.value / "accounts" / testAccountId1.value / SYSTEM_OWNER_VIEW_ID + val managementUrl = v4_0_0_Request / "management" / "banks" / testBankId1.value / "accounts" / testAccountId1.value / SYSTEM_OWNER_VIEW_ID + + scenario("Get and Delete through a view refuse a counterparty of another account", VersionOfApi, ApiEndpoint6, ApiEndpoint7) { + val counterpartyId = counterpartyOfTestAccount0() + + val getResponse = makeGetRequest((accountUrl / "counterparties" / counterpartyId).GET <@ user1) + getResponse.code should equal(400) + getResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId) + + val deleteResponse = makeDeleteRequest((accountUrl / "counterparties" / counterpartyId).DELETE <@ user1) + deleteResponse.code should equal(400) + deleteResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId) + stillExists(counterpartyId) should equal(true) + } + + scenario("The management Get and Delete refuse a counterparty of another account", VersionOfApi, ApiEndpoint3, ApiEndpoint4) { + val counterpartyId = counterpartyOfTestAccount0() + val entitlements = List(CanGetCounterparty, CanDeleteCounterparty).map(role => + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, role.toString)) + + val getResponse = makeGetRequest((managementUrl / "counterparties" / counterpartyId).GET <@ user1) + getResponse.code should equal(400) + getResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId) + + val deleteResponse = makeDeleteRequest((managementUrl / "counterparties" / counterpartyId).DELETE <@ user1) + deleteResponse.code should equal(400) + deleteResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId) + stillExists(counterpartyId) should equal(true) + + entitlements.foreach(Entitlement.entitlement.vend.deleteEntitlement(_)) + } + + scenario("A COUNTERPARTY transaction request refuses a counterparty of another account", VersionOfApi) { + val counterpartyId = counterpartyOfTestAccount0() + val body = code.api.v2_1_0.TransactionRequestBodyCounterpartyJSON( + code.api.v2_1_0.CounterpartyIdJson(counterpartyId), + com.openbankproject.commons.model.AmountOfMoneyJsonV121("EUR", "1"), + "Pay a counterparty of another account", + "SHARED") + + val response = makePostRequest( + (accountUrl / "transaction-request-types" / "COUNTERPARTY" / "transaction-requests").POST <@ user1, write(body)) + response.code should equal(400) + response.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId) + } + } } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/RefundTransactionRequestTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/RefundTransactionRequestTest.scala new file mode 100644 index 0000000000..5509c1edc2 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v4_0_0/RefundTransactionRequestTest.scala @@ -0,0 +1,264 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, 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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.v4_0_0 + +import code.api.Constant.SYSTEM_OWNER_VIEW_ID +import code.api.util.APIUtil.OAuth._ +import code.api.util.ErrorMessages +import code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140 +import code.api.v2_0_0.TransactionRequestBodyJsonV200 +import code.metadata.counterparties.Counterparties +import code.model.BankAccountX +import com.openbankproject.commons.model.{AccountId, AmountOfMoneyJsonV121, BankId, ErrorMessage} +import com.openbankproject.commons.util.ApiVersion +import org.json4s.native.Serialization.write +import org.scalatest.Tag + +/** + * This suite covers the checks on a REFUND transaction request. A refund either pays back money the + * account received (`from`) or asks for money the account paid out to be sent back (`to`). In both + * cases OBP-API may only debit the account the caller is authorised on, the refund must be for a + * payment between the account and the named party, and a payment can be refunded only once. + */ +class RefundTransactionRequestTest extends V400ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v4_0_0.toString) + + // The caller's account (A) and the other party (B), both at the same bank. user1 has the owner view + // on both, so only the refund rules themselves can refuse the requests below. + val bankId = testBankId1 + val callerAccountId = testAccountId1 + val otherPartyAccountId = testAccountId0 + // An account that has nothing to do with the payment being refunded. + val unrelatedBankId = testBankId2 + val unrelatedAccountId = testAccountId0 + + def transactionRequestsUrl(bank: BankId, account: AccountId, transactionRequestType: String) = + v4_0_0_Request / "banks" / bank.value / "accounts" / account.value / SYSTEM_OWNER_VIEW_ID / + "transaction-request-types" / transactionRequestType / "transaction-requests" + + override def beforeEach(): Unit = { + super.beforeEach() + setPropsValues( + "transactionRequests_supported_types" -> "SANDBOX_TAN,COUNTERPARTY,SEPA,ACCOUNT_OTP,ACCOUNT,SIMPLE,REFUND", + "REFUND_OTP_INSTRUCTION_TRANSPORT" -> "DUMMY") + } + + /** This pays 1 EUR from the caller's account to the other party and returns the transaction id. */ + def payTheOtherParty(): String = { + val body = TransactionRequestBodyJsonV200( + TransactionRequestAccountJsonV140(bankId.value, otherPartyAccountId.value), + AmountOfMoneyJsonV121("EUR", "1"), + "A payment that may later be refunded") + val response = makePostRequest(transactionRequestsUrl(bankId, callerAccountId, "ACCOUNT").POST <@ user1, write(body)) + response.code should equal(201) + val created = response.body.extract[TransactionRequestWithChargeJSON400] + created.transaction_ids.headOption.getOrElse(fail(s"The payment did not complete: ${created.status}")) + } + + def refundBody(transactionId: String, to: Option[TransactionRequestRefundTo], from: Option[TransactionRequestRefundFrom]) = + TransactionRequestBodyRefundJsonV400( + to = to, + from = from, + value = AmountOfMoneyJsonV121("EUR", "1"), + description = "Refund", + refund = RefundJson(transaction_id = transactionId, reason_code = "CUST")) + + def askForMoneyBack(transactionId: String, fromBank: BankId, fromAccount: AccountId) = + makePostRequest( + transactionRequestsUrl(bankId, callerAccountId, "REFUND").POST <@ user1, + write(refundBody(transactionId, Some(TransactionRequestRefundTo(Some(fromBank.value), Some(fromAccount.value), None)), None))) + + /** + * This creates a beneficiary counterparty on the caller's account that points at the other party's + * account, with that account's id as its IBAN: the shape the challenge-answer step looks for when it + * works out which account a `to` refund debits. + */ + def counterpartyOnCallerAccountPointingAtOtherParty(): String = + Counterparties.counterparties.vend.createCounterparty( + createdByUserId = resourceUser1.userId, + thisBankId = bankId.value, + thisAccountId = callerAccountId.value, + thisViewId = "", + name = java.util.UUID.randomUUID.toString, + otherAccountRoutingScheme = "OBP", + otherAccountRoutingAddress = otherPartyAccountId.value, + otherBankRoutingScheme = "OBP", + otherBankRoutingAddress = bankId.value, + otherBranchRoutingScheme = "OBP", + otherBranchRoutingAddress = "Berlin", + isBeneficiary = true, + otherAccountSecondaryRoutingScheme = "IBAN", + otherAccountSecondaryRoutingAddress = otherPartyAccountId.value, + description = "", + currency = "EUR", + bespoke = Nil + ).map(_.counterpartyId).openOrThrowException("The counterparty could not be created") + + /** + * This creates a beneficiary counterparty on the caller's account for an account at another bank, which + * OBP-API represents by a stand-in account built from the counterparty's routings, and pays it 1 EUR. + * It returns the counterparty id and the transaction id. + */ + def payAnExternalParty(): (String, String) = { + // On the local connector, money to or from another bank passes through the default bank's + // settlement accounts, so the test bank needs them. + List(code.api.Constant.INCOMING_SETTLEMENT_ACCOUNT_ID, code.api.Constant.OUTGOING_SETTLEMENT_ACCOUNT_ID).foreach { settlementAccountId => + if (code.model.dataAccess.MappedBankAccount.find( + net.liftweb.mapper.By(code.model.dataAccess.MappedBankAccount.bank, code.api.util.APIUtil.defaultBankId), + net.liftweb.mapper.By(code.model.dataAccess.MappedBankAccount.theAccountId, settlementAccountId)).isEmpty) + code.model.dataAccess.MappedBankAccount.create + .bank(code.api.util.APIUtil.defaultBankId).theAccountId(settlementAccountId).accountCurrency("EUR").saveMe() + } + val externalAccountReference = "EXTERNAL-" + java.util.UUID.randomUUID.toString.take(8) + val counterpartyId = Counterparties.counterparties.vend.createCounterparty( + createdByUserId = resourceUser1.userId, + thisBankId = bankId.value, + thisAccountId = callerAccountId.value, + thisViewId = "", + name = java.util.UUID.randomUUID.toString, + otherAccountRoutingScheme = "BBAN", + otherAccountRoutingAddress = externalAccountReference, + otherBankRoutingScheme = "BIC", + otherBankRoutingAddress = "EXTERNALXXX", + otherBranchRoutingScheme = "", + otherBranchRoutingAddress = "", + isBeneficiary = true, + otherAccountSecondaryRoutingScheme = "IBAN", + otherAccountSecondaryRoutingAddress = externalAccountReference, + description = "", + currency = "EUR", + bespoke = Nil + ).map(_.counterpartyId).openOrThrowException("The counterparty could not be created") + val body = code.api.v2_1_0.TransactionRequestBodyCounterpartyJSON( + code.api.v2_1_0.CounterpartyIdJson(counterpartyId), AmountOfMoneyJsonV121("EUR", "1"), "A payment to another bank", "SHARED") + val response = makePostRequest(transactionRequestsUrl(bankId, callerAccountId, "COUNTERPARTY").POST <@ user1, write(body)) + response.code should equal(201) + val created = response.body.extract[TransactionRequestWithChargeJSON400] + (counterpartyId, created.transaction_ids.headOption.getOrElse(fail(s"The payment did not complete: ${created.status}"))) + } + + def balanceOf(account: AccountId): BigDecimal = + BankAccountX(bankId, account).map(_.balance).openOrThrowException("Account not found") + + feature("A REFUND only debits the account the caller is authorised on") { + + scenario("Answering the challenge of a `to` refund does not debit another account on this instance", VersionOfApi) { + Given("A payment from the caller to the other party, and a refund request asking for it back") + val transactionId = payTheOtherParty() + counterpartyOnCallerAccountPointingAtOtherParty() + val refundResponse = askForMoneyBack(transactionId, bankId, otherPartyAccountId) + refundResponse.code should equal(201) + val refund = refundResponse.body.extract[TransactionRequestWithChargeJSON400] + val challenge = refund.challenges.find(_.user_id == resourceUser1.userId).getOrElse(fail("No challenge for the caller")) + val otherPartyBalanceBefore = balanceOf(otherPartyAccountId) + + When("The caller answers the challenge on their own account") + val answerResponse = makePostRequest( + (transactionRequestsUrl(bankId, callerAccountId, "REFUND") / refund.id / "challenge").POST <@ user1, + write(ChallengeAnswerJson400(id = challenge.id, answer = "123"))) + + Then("The payment is refused and the other party's account is not debited") + answerResponse.code should equal(400) + answerResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.InsufficientAuthorisationToCreateTransactionRequest) + balanceOf(otherPartyAccountId) should equal(otherPartyBalanceBefore) + } + + scenario("Without an adapter making the payment, a `to` refund from an external party is not paid", VersionOfApi) { + Given("A payment to an account at another bank, and a refund request asking for it back") + val (counterpartyId, transactionId) = payAnExternalParty() + val refundResponse = makePostRequest( + transactionRequestsUrl(bankId, callerAccountId, "REFUND").POST <@ user1, + write(refundBody(transactionId, Some(TransactionRequestRefundTo(None, None, Some(counterpartyId))), None))) + refundResponse.code should equal(201) + val refund = refundResponse.body.extract[TransactionRequestWithChargeJSON400] + val challenge = refund.challenges.find(_.user_id == resourceUser1.userId).getOrElse(fail("No challenge for the caller")) + val callerBalanceBefore = balanceOf(callerAccountId) + + When("The caller answers the challenge, on an instance where the local connector would make the payment") + val answerResponse = makePostRequest( + (transactionRequestsUrl(bankId, callerAccountId, "REFUND") / refund.id / "challenge").POST <@ user1, + write(ChallengeAnswerJson400(id = challenge.id, answer = "123"))) + + Then("The payment is refused and the caller's account is not credited") + answerResponse.code should equal(400) + answerResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.InvalidTransactionRequestType) + balanceOf(callerAccountId) should equal(callerBalanceBefore) + } + + scenario("A challenge can't be answered through an account the transaction request does not involve", VersionOfApi) { + Given("A refund request between the caller's account and the other party") + val transactionId = payTheOtherParty() + val refund = askForMoneyBack(transactionId, bankId, otherPartyAccountId).body.extract[TransactionRequestWithChargeJSON400] + val challenge = refund.challenges.find(_.user_id == resourceUser1.userId).getOrElse(fail("No challenge for the caller")) + + When("The challenge is answered through an unrelated account") + val answerResponse = makePostRequest( + (transactionRequestsUrl(unrelatedBankId, unrelatedAccountId, "REFUND") / refund.id / "challenge").POST <@ user1, + write(ChallengeAnswerJson400(id = challenge.id, answer = "123"))) + + Then("The transaction request is not found for that account") + answerResponse.code should equal(400) + answerResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.InvalidTransactionRequestId) + } + } + + feature("A REFUND must be for a payment between the account and the named party") { + + scenario("A `to` refund naming a party the payment was not made with is refused", VersionOfApi) { + val transactionId = payTheOtherParty() + val response = askForMoneyBack(transactionId, unrelatedBankId, unrelatedAccountId) + response.code should equal(400) + response.body.extract[ErrorMessage].message should startWith(ErrorMessages.RefundTransactionNotWithThisParty) + } + + scenario("A `from` refund of a payment the account made, not received, is refused", VersionOfApi) { + Given("A payment the caller made to the other party") + val transactionId = payTheOtherParty() + val counterpartyId = counterpartyOnCallerAccountPointingAtOtherParty() + + When("The caller tries to pay it back to the other party as if they had received it") + val response = makePostRequest( + transactionRequestsUrl(bankId, callerAccountId, "REFUND").POST <@ user1, + write(refundBody(transactionId, None, Some(TransactionRequestRefundFrom(counterpartyId))))) + + Then("The refund is refused") + response.code should equal(400) + response.body.extract[ErrorMessage].message should startWith(ErrorMessages.RefundTransactionNotWithThisParty) + } + + scenario("A payment can be refunded only once", VersionOfApi) { + val transactionId = payTheOtherParty() + askForMoneyBack(transactionId, bankId, otherPartyAccountId).code should equal(201) + + val secondResponse = askForMoneyBack(transactionId, bankId, otherPartyAccountId) + secondResponse.code should equal(400) + secondResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.RefundedTransaction) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v5_0_0/UserAuthContextTest.scala b/obp-api/src/test/scala/code/api/v5_0_0/UserAuthContextTest.scala index b72910da9b..a9e4aa3da5 100644 --- a/obp-api/src/test/scala/code/api/v5_0_0/UserAuthContextTest.scala +++ b/obp-api/src/test/scala/code/api/v5_0_0/UserAuthContextTest.scala @@ -36,6 +36,8 @@ import code.api.util.ErrorMessages._ import code.api.v3_1_0.CustomerJsonV310 import code.api.v5_0_0.Http4s500.Implementations5_0_0 import code.entitlement.Entitlement +import code.usercustomerlinks.UserCustomerLink +import com.openbankproject.commons.model.UserAuthContextUpdateStatus import com.github.dwickern.macros.NameOf.nameOf import org.json4s.native.Serialization.write import org.scalatest.Tag @@ -63,7 +65,7 @@ class UserAuthContextTest extends V500ServerSetup { feature("Add/Get Auth Context v5.0.0") { scenario("We will call the Add endpoint without a user credentials", ApiEndpoint1, VersionOfApi) { When("We make a request v5.0.0") - val request500 = (v5_0_0_Request / "users" / userId1.value / "auth-context").POST + val request500 = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").POST val response500 = makePostRequest(request500, write(postUserAuthContextJsonV310)) Then("We should get a 401") response500.code should equal(401) @@ -72,7 +74,7 @@ class UserAuthContextTest extends V500ServerSetup { } scenario("We will call the Add endpoint without a proper role", ApiEndpoint1, VersionOfApi) { When("We make a request v5.0.0") - val request500 = (v5_0_0_Request / "users" / userId1.value / "auth-context").POST <@(user1) + val request500 = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").POST <@(user1) val response500 = makePostRequest(request500, write(postUserAuthContextJsonV310)) Then("We should get a 403") response500.code should equal(403) @@ -82,7 +84,7 @@ class UserAuthContextTest extends V500ServerSetup { scenario("We will call the Get endpoint without a user credentials", ApiEndpoint2, VersionOfApi) { When("We make a request v5.0.0") - val request500 = (v5_0_0_Request / "users" / userId1.value / "auth-context").GET + val request500 = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").GET val response500 = makeGetRequest(request500) Then("We should get a 401") response500.code should equal(401) @@ -91,7 +93,7 @@ class UserAuthContextTest extends V500ServerSetup { } scenario("We will call the Get endpoint without a proper role", ApiEndpoint2, VersionOfApi) { When("We make a request v5.0.0") - val request500 = (v5_0_0_Request / "users" / userId1.value / "auth-context").GET <@(user1) + val request500 = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").GET <@(user1) val response500 = makeGetRequest(request500) Then("We should get a 403") response500.code should equal(403) @@ -103,14 +105,14 @@ class UserAuthContextTest extends V500ServerSetup { scenario("We will call the Add, Get and Delete endpoints with user credentials and role", ApiEndpoint1, ApiEndpoint2, VersionOfApi) { When("We try to create the UserAuthContext v5.0.0") Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateUserAuthContext.toString) - val requestUserAuthContext500 = (v5_0_0_Request / "users" / userId1.value / "auth-context").POST <@(user1) + val requestUserAuthContext500 = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").POST <@(user1) val responseUserAuthContext500 = makePostRequest(requestUserAuthContext500, write(postUserAuthContextJsonV310)) Then("We should get a 201") responseUserAuthContext500.code should equal(201) val customerJson = responseUserAuthContext500.body.extract[UserAuthContextJsonV500] When("We try to create the UserAuthContext v5.0.0") - val successReq = (v5_0_0_Request / "users" / userId1.value / "auth-context").POST <@(user1) + val successReq = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").POST <@(user1) val successRes = makePostRequest(successReq, write(postUserAuthContextJsonV5002)) Then("We should get a 201") successRes.code should equal(201) @@ -118,12 +120,12 @@ class UserAuthContextTest extends V500ServerSetup { When("We try to make the GET request v5.0.0") Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetUserAuthContext.toString) - val successGetReq = (v5_0_0_Request / "users" / userId1.value / "auth-context").GET <@(user1) + val successGetReq = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").GET <@(user1) val successGetRes = makeGetRequest(successGetReq) Then("We should get a 200") successGetRes.code should equal(200) val userAuthContexts = successGetRes.body.extract[UserAuthContextsJsonV500] - userAuthContexts.user_auth_contexts.map(_.user_id).forall(userId1.value ==) shouldBe (true) + userAuthContexts.user_auth_contexts.map(_.user_id).forall(resourceUser1.userId ==) shouldBe (true) userAuthContexts.user_auth_contexts.map(_.consumer_id).forall(testConsumer.consumerId.get ==) shouldBe (true) } @@ -177,16 +179,19 @@ class UserAuthContextTest extends V500ServerSetup { Then("We should get a 200") successRes.code should equal(200) val result = successRes.body.extract[UserAuthContextUpdateJsonV500] + And("The example answer is wrong, so the update is rejected and the User is not linked to the Customer") + result.status should equal(UserAuthContextUpdateStatus.REJECTED.toString) + UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser1.userId, infoPost.customer_id).isDefined should equal(false) When("We try to make the GET request v5.0.0") Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetUserAuthContext.toString) - val successGetReq = (v5_0_0_Request / "users" / userId1.value / "auth-context").GET <@(user1) + val successGetReq = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").GET <@(user1) val successGetRes = makeGetRequest(successGetReq) Then("We should get a 200") successGetRes.code should equal(200) val userAuthContexts = successGetRes.body.extract[UserAuthContextsJsonV500] - userAuthContexts.user_auth_contexts.map(_.user_id).forall(userId1.value ==) shouldBe (true) + userAuthContexts.user_auth_contexts.map(_.user_id).forall(resourceUser1.userId ==) shouldBe (true) userAuthContexts.user_auth_contexts.map(_.consumer_id).forall(testConsumer.consumerId.get ==) shouldBe (true) } diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerRedirectUrlValidationTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerRedirectUrlValidationTest.scala new file mode 100644 index 0000000000..569f480e38 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerRedirectUrlValidationTest.scala @@ -0,0 +1,171 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, 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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.v5_1_0 + +import java.util.Date + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole._ +import code.api.util.ErrorMessages.{InvalidRedirectUrl, RedirectUrlHostNotAllowed} +import code.api.v2_1_0.{ConsumerPostJSON, ConsumerRedirectUrlJSON} +import code.consumer.Consumers +import code.entitlement.Entitlement +import code.model.Consumer +import code.setup.APIResponse +import net.liftweb.util.Helpers.randomString +import org.json4s._ +import org.json4s.native.Serialization.write + +/** + * This suite checks that every endpoint that writes a Consumer's redirect URL rejects one that breaks the + * redirect URL rules (code.api.util.RedirectUrlValidation), and that the OIDC client endpoints never hand + * out a stored entry that breaks them. + */ +class ConsumerRedirectUrlValidationTest extends V510ServerSetup { + + private val v2_1_0 = baseRequest / "obp" / "v2.1.0" + private val v2_2_0 = baseRequest / "obp" / "v2.2.0" + private val v4_0_0 = baseRequest / "obp" / "v4.0.0" + private val v6_0_0 = baseRequest / "obp" / "v6.0.0" + + private val badRedirectUrl = "https://app.example.com/cb,javascript:alert(1)" + + private def createConsumerRequestJson(redirectUrl: String) = CreateConsumerRequestJsonV510( + app_name = "redirect-test-" + randomString(8), app_type = "Confidential", description = "redirect test", + developer_email = "developer@example.com", company = "Example", redirect_url = redirectUrl, + enabled = true, client_certificate = None, logo_url = None) + + private def consumerPostJson(redirectUrl: String) = ConsumerPostJSON( + "redirect-test-" + randomString(8), "Confidential", "redirect test", "developer@example.com", + redirectUrl, "", true, new Date(), "") + + private def messageOf(body: JValue): String = (body \ "message") match { + case JString(message) => message + case _ => "" + } + + private def shouldBeRejected(response: APIResponse): Unit = { + response.code should equal(400) + messageOf(response.body) should startWith(InvalidRedirectUrl) + messageOf(response.body) should include("javascript:alert(1)") + } + + private def createConsumerOwnedByUser1(redirectUrl: String): String = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString) + val response = makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1), write(createConsumerRequestJson(redirectUrl))) + response.code should equal(201) + (response.body \ "consumer_id").extract[String] + } + + feature("Creating a Consumer with a redirect URL that breaks the rules is rejected") { + + scenario("v5.1.0 createConsumer") { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString) + shouldBeRejected(makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1), write(createConsumerRequestJson(badRedirectUrl)))) + } + + scenario("v5.1.0 createMyConsumer") { + shouldBeRejected(makePostRequest((v5_1_0_Request / "my" / "consumers").POST <@ (user1), write(createConsumerRequestJson(badRedirectUrl)))) + } + + scenario("v4.0.0 createConsumer") { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString) + shouldBeRejected(makePostRequest((v4_0_0 / "management" / "consumers").POST <@ (user1), write(consumerPostJson(badRedirectUrl)))) + } + + scenario("v2.2.0 createConsumer") { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString) + shouldBeRejected(makePostRequest((v2_2_0 / "management" / "consumers").POST <@ (user1), write(consumerPostJson(badRedirectUrl)))) + } + + scenario("an allowed redirect URL is still accepted") { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString) + val response = makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1), + write(createConsumerRequestJson("https://app.example.com/cb http://localhost:5173/cb com.example.app:/cb"))) + response.code should equal(201) + } + } + + feature("redirect_url_allowed_hosts limits the hosts a Consumer redirect URL may point to") { + + scenario("with the prop unset, any host that meets the rules is accepted") { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString) + val response = makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1), + write(createConsumerRequestJson("https://any-third-party.example/cb"))) + response.code should equal(201) + } + + scenario("with the prop set, an unlisted host is refused with RedirectUrlHostNotAllowed and a listed one is accepted") { + setPropsValues("redirect_url_allowed_hosts" -> ".bank.example") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString) + + val refused = makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1), + write(createConsumerRequestJson("https://evil.example/cb"))) + refused.code should equal(400) + messageOf(refused.body) should startWith(RedirectUrlHostNotAllowed) + + val accepted = makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1), + write(createConsumerRequestJson("https://portal.bank.example/cb"))) + accepted.code should equal(201) + } + } + + feature("Updating a Consumer's redirect URL to one that breaks the rules is rejected") { + + scenario("v5.1.0 updateConsumerRedirectURL") { + val consumerId = createConsumerOwnedByUser1("https://app.example.com/cb") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerRedirectUrl.toString) + shouldBeRejected(makePutRequest((v5_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "redirect_url").PUT <@ (user1), + write(ConsumerRedirectUrlJSON(badRedirectUrl)))) + } + + scenario("v2.1.0 updateConsumerRedirectUrl") { + val consumerId = createConsumerOwnedByUser1("https://app.example.com/cb") + val primaryId = Consumers.consumers.vend.getConsumerByConsumerId(consumerId).map(_.id.get).openOrThrowException("consumer just created") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerRedirectUrl.toString) + shouldBeRejected(makePutRequest((v2_1_0 / "management" / "consumers" / primaryId.toString / "consumer" / "redirect_url").PUT <@ (user1), + write(ConsumerRedirectUrlJSON(badRedirectUrl)))) + } + } + + feature("The OIDC client endpoints leave out stored entries that break the rules") { + + scenario("getOidcClient returns only the allowed entries of a redirect URL saved before the rules existed") { + // Saved without validation, as a Consumer written before the redirect URL rules existed would be. + val key = randomString(40).toLowerCase + Consumer.create.key(key).secret(randomString(40).toLowerCase).name("legacy-" + randomString(8)) + .description("legacy").isActive(true) + .redirectURL("https://app.example.com/cb,javascript:alert(1) http://public.example.com/cb").saveMe() + + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canGetOidcClient.toString) + val response = makeGetRequest((v6_0_0 / "oidc" / "clients" / key).GET <@ (user1)) + response.code should equal(200) + (response.body \ "redirect_uris").extract[List[String]] should equal(List("https://app.example.com/cb")) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala index 0beecdd1fa..0ae1a9fd5e 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala @@ -259,10 +259,13 @@ class ConsumerTest extends V510ServerSetup { } + // A Consumer name that is already taken gets a random suffix, and the Consumer table survives + // between runs, so this Consumer gets a name no earlier run can have used. + val myConsumerRequestJson = createConsumerRequestJsonV510.copy(app_name = "myConsumer" + java.util.UUID.randomUUID.toString) val requestApiEndpoint6 = (v5_1_0_Request / "my" / "consumers").POST<@ (user1) - val responseApiEndpoint6 = makePostRequest(requestApiEndpoint6, write(createConsumerRequestJsonV510)) + val responseApiEndpoint6 = makePostRequest(requestApiEndpoint6, write(myConsumerRequestJson)) val consumerJson6 = responseApiEndpoint6.body.extract[ConsumerJsonOnlyForPostResponseV510] - consumerJson6.app_name shouldBe createConsumerRequestJsonV510.app_name + consumerJson6.app_name shouldBe myConsumerRequestJson.app_name consumerJson6.redirect_url shouldBe createConsumerRequestJsonV510.redirect_url consumerJson6.logo_url.headOption shouldBe createConsumerRequestJsonV510.logo_url.headOption consumerJson6.description shouldBe createConsumerRequestJsonV510.description diff --git a/obp-api/src/test/scala/code/api/v5_1_0/WaitingForGodotTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/WaitingForGodotTest.scala new file mode 100644 index 0000000000..2a88b13875 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v5_1_0/WaitingForGodotTest.scala @@ -0,0 +1,99 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, 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. +Osloer Strasse 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.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanGetWaitingForGodot +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles} +import code.entitlement.Entitlement +import code.setup.APIResponse +import org.json4s._ + +/** + * This suite checks that waitingForGodot needs an authenticated User with the Role CanGetWaitingForGodot, + * and that it only ever delays for between zero and the instance's maximum, however large or invalid the + * requested delay is. + */ +class WaitingForGodotTest extends V510ServerSetup { + + private def waitingForGodot(sleep: String) = (v5_1_0_Request / "waiting-for-godot").GET < sleep) + + private def sleepReported(response: APIResponse): Long = (response.body \ "sleep_in_milliseconds").extract[Long] + + private def messageOf(response: APIResponse): String = (response.body \ "message").extractOpt[String].getOrElse("") + + private def grantRoleToUser1(): Unit = + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetWaitingForGodot.toString) + + feature("waitingForGodot needs authentication and a Role") { + + scenario("an anonymous caller is refused with 401") { + val response = makeGetRequest(waitingForGodot("20")) + response.code should equal(401) + messageOf(response) should include(AuthenticatedUserIsRequired) + } + + scenario("a User without the Role is refused with 403") { + val response = makeGetRequest(waitingForGodot("20") <@ (user2)) + response.code should equal(403) + messageOf(response) should equal(UserHasMissingRoles + CanGetWaitingForGodot) + } + } + + feature("waitingForGodot delays only within bounds") { + + scenario("a User with the Role gets the delay it asks for when it is within the maximum") { + grantRoleToUser1() + val response = makeGetRequest(waitingForGodot("20") <@ (user1)) + response.code should equal(200) + sleepReported(response) should equal(20L) + } + + scenario("a delay above the maximum is reduced to the maximum") { + grantRoleToUser1() + setPropsValues("waiting_for_godot_max_sleep_in_milliseconds" -> "50") + val started = System.currentTimeMillis() + val response = makeGetRequest(waitingForGodot(Long.MaxValue.toString) <@ (user1)) + val elapsed = System.currentTimeMillis() - started + response.code should equal(200) + sleepReported(response) should equal(50L) + elapsed should be < 10000L + } + + scenario("a negative or unreadable delay counts as zero instead of failing") { + grantRoleToUser1() + List("-1", "not-a-number").foreach { requested => + val response = makeGetRequest(waitingForGodot(requested) <@ (user1)) + withClue(requested) { + response.code should equal(200) + sleepReported(response) should equal(0L) + } + } + } + } +} diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala index 57be013044..6091cf343d 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala @@ -53,8 +53,25 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { val bankId = testBankId1.value val accountId = testAccountId1.value - lazy val counterpartyId = createMockCounterparty() - lazy val attributeId = createMockAttribute(counterpartyId) + // The test data, counterparties included, is wiped before every scenario, so each scenario gets its + // own counterparty and attribute, created on first use after that wipe. + private var counterpartyOfThisScenario: Option[String] = None + private var attributeOfThisScenario: Option[String] = None + def counterpartyId: String = counterpartyOfThisScenario.getOrElse { + val created = createMockCounterparty() + counterpartyOfThisScenario = Some(created) + created + } + def attributeId: String = attributeOfThisScenario.getOrElse { + val created = createMockAttribute(counterpartyId) + attributeOfThisScenario = Some(created) + created + } + override def beforeEach(): Unit = { + super.beforeEach() + counterpartyOfThisScenario = None + attributeOfThisScenario = None + } def createMockCounterparty(): String = { val counterparty = createCounterparty(bankId, accountId, accountId, true, UUID.randomUUID.toString) @@ -148,8 +165,6 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { } feature("Delete Counterparty Attribute") { - lazy val counterpartyId = createMockCounterparty() - lazy val attributeId = createMockAttribute(counterpartyId) scenario("401 Unauthorized", Delete, VersionOfApi) { val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).DELETE val response = makeDeleteRequest(request) @@ -175,8 +190,6 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { } feature("Get All Counterparty Attributes") { - lazy val counterpartyId = createMockCounterparty() - lazy val attributeId = createMockAttribute(counterpartyId) scenario("401 Unauthorized", GetAll, VersionOfApi) { val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").GET val response = makeGetRequest(request) @@ -200,7 +213,6 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { } feature("Get Counterparty Attribute by ID") { - lazy val counterpartyId = createMockCounterparty() scenario("401 Unauthorized", GetOne, VersionOfApi) { val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).GET @@ -224,4 +236,55 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { Entitlement.entitlement.vend.deleteEntitlement(entitlement) } } + + feature("Counterparty Attributes are only reachable through the counterparty's own account") { + + scenario("400 when the counterparty belongs to a different account", Create, GetAll, VersionOfApi) { + Given("A counterparty of testAccount0, addressed through testAccount1") + val otherAccountId = testAccountId0.value + val counterpartyOfOtherAccount = createCounterparty(bankId, otherAccountId, otherAccountId, true, UUID.randomUUID.toString).counterpartyId + val createEntitlement = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateCounterpartyAttribute.toString) + val getEntitlement = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanGetCounterpartyAttributes.toString) + val attributesUrl = v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyOfOtherAccount / "attributes" + + Then("Creating an attribute is refused and nothing is created") + val createResponse = makePostRequest(attributesUrl.POST <@ user1, write(counterpartyAttributeRequestJsonV600)) + createResponse.code should equal(400) + createResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId) + + And("Listing its attributes is refused") + val listResponse = makeGetRequest(attributesUrl.GET <@ user1) + listResponse.code should equal(400) + listResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId) + + Entitlement.entitlement.vend.deleteEntitlement(createEntitlement) + Entitlement.entitlement.vend.deleteEntitlement(getEntitlement) + } + + scenario("404 when the attribute belongs to a different counterparty", GetOne, Update, Delete, VersionOfApi) { + Given("An attribute of one counterparty, addressed through another counterparty of the same account") + val attributeOfFirstCounterparty = createMockAttribute(createMockCounterparty()) + val secondCounterparty = createMockCounterparty() + val entitlements = List(CanGetCounterpartyAttribute, CanUpdateCounterpartyAttribute, CanDeleteCounterpartyAttribute).map(role => + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, role.toString)) + val attributeUrl = v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / secondCounterparty / "attributes" / attributeOfFirstCounterparty + + Then("Getting, updating and deleting it are all refused") + val getResponse = makeGetRequest(attributeUrl.GET <@ user1) + getResponse.code should equal(404) + getResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyAttributeNotFound) + val updateResponse = makePutRequest(attributeUrl.PUT <@ user1, write(counterpartyAttributeRequestJsonV600)) + updateResponse.code should equal(404) + val deleteResponse = makeDeleteRequest(attributeUrl.DELETE <@ user1) + deleteResponse.code should equal(404) + + And("The attribute is untouched") + scala.concurrent.Await.result( + code.counterpartyattribute.CounterpartyAttributeX.counterpartyAttributeProvider.vend + .getCounterpartyAttributeById(attributeOfFirstCounterparty), + scala.concurrent.duration.Duration(10, "seconds")).isDefined should equal(true) + + entitlements.foreach(Entitlement.entitlement.vend.deleteEntitlement(_)) + } + } } diff --git a/obp-api/src/test/scala/code/setup/DefaultUsers.scala b/obp-api/src/test/scala/code/setup/DefaultUsers.scala index 78bdd82478..5b0d2e196a 100644 --- a/obp-api/src/test/scala/code/setup/DefaultUsers.scala +++ b/obp-api/src/test/scala/code/setup/DefaultUsers.scala @@ -71,7 +71,9 @@ trait DefaultUsers { description = Some("test1 description"), developerEmail = Some("eveline@example.com"), redirectURL = None, - createdByUserId = userId1, + // The ResourceUser can survive from an earlier run with a different user id (the reset keeps that + // table, while userId1 is new on every JVM start), so take the owner from the user actually used. + createdByUserId = Some(resourceUser1.userId), None, None, None @@ -87,7 +89,7 @@ trait DefaultUsers { description = Some("test2 description"), developerEmail = Some("eveline@example.com"), redirectURL = None, - createdByUserId = userId2, + createdByUserId = Some(resourceUser2.userId), None, None, None, @@ -103,7 +105,7 @@ trait DefaultUsers { description = Some("test3 description"), developerEmail = Some("eveline@example.com"), redirectURL = None, - createdByUserId = userId3, + createdByUserId = Some(resourceUser3.userId), None, None, None @@ -119,7 +121,7 @@ trait DefaultUsers { description = Some("test4 description"), developerEmail = Some("eveline@example.com"), redirectURL = None, - createdByUserId = userId4, + createdByUserId = Some(resourceUser4.userId), None, None, None diff --git a/obp-api/src/test/scala/code/util/HelperTest.scala b/obp-api/src/test/scala/code/util/HelperTest.scala index 724dad94de..ab9bea7098 100644 --- a/obp-api/src/test/scala/code/util/HelperTest.scala +++ b/obp-api/src/test/scala/code/util/HelperTest.scala @@ -35,34 +35,6 @@ import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers} class HelperTest extends FeatureSpec with Matchers with GivenWhenThen with PropsReset { - feature("test Helper.getStaticPortionOfRedirectURL method") { - // The redirectURl is `http://localhost:8082/oauthcallback` - val testString1 = "http://localhost:8082/oauthcallback?oauth_token=G5AEA2U1WG404EGHTIGBHKRR4YJZAPPHWKOMNEEV&oauth_verifier=53018" - val testString2 = "http://localhost:8082?oauth_token=G5AEA2U1WG404EGHTIGBHKRR4YJZAPPHWKOMNEEV&oauth_verifier=53018" - val testString3 = "myapp://callback?oauth_token=%3DEBRZBMOPDXEUGGJP421FPFGK01IY2DGM5O3TLVSK%26oauth_verifier%3D63461" - val testString4 = "fb00000000:://callback?oauth_token=%3DEBRZBMOPDXEUGGJP421FPFGK01IY2DGM5O3TLVSK%26oauth_verifier%3D63461" - val testString5 = "http://127.0.0.1:8000/oauth/authorize?next=/en/metrics/api/&oauth_token=TN0124OCPRCL4KUJRF5LNLVMRNHTVZPJDBS2PNWU&oauth_verifier=10470" - - Helper.getStaticPortionOfRedirectURL(testString1).head should be("http://localhost:8082/oauthcallback") - Helper.getStaticPortionOfRedirectURL(testString2).head should be("http://localhost:8082") - Helper.getStaticPortionOfRedirectURL(testString3).head should be("myapp://callback") - Helper.getStaticPortionOfRedirectURL(testString4).head should be("fb00000000:://callback") - Helper.getStaticPortionOfRedirectURL(testString5).head should be("http://127.0.0.1:8000/oauth/authorize") - } - - feature("test Helper.getHostOnlyOfRedirectURL method") { - // The redirectURl is `http://localhost:8082/oauthcallback` - val testString1 = "http://localhost:8082/oauthcallback?oauth_token=G5AEA2U1WG404EGHTIGBHKRR4YJZAPPHWKOMNEEV&oauth_verifier=53018" - val testString2 = "http://localhost:8082/oauthcallback" - val testString3 = "http://localhost:8082?oauth_token=G5AEA2U1WG404EGHTIGBHKRR4YJZAPPHWKOMNEEV&oauth_verifier=53018" - val testString4 = "http://localhost:8082" - - Helper.getHostOnlyOfRedirectURL(testString1).head should be("http://localhost:8082") - Helper.getHostOnlyOfRedirectURL(testString2).head should be("http://localhost:8082") - Helper.getHostOnlyOfRedirectURL(testString3).head should be("http://localhost:8082") - Helper.getHostOnlyOfRedirectURL(testString4).head should be("http://localhost:8082") - } - feature(s"test Helper.getIfNotExistsAddedColumLengthForMsSqlServer method") { scenario(s"test case addColumnIfNotExists") { diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json index 3a1e7f30c8..e371131ee3 100644 --- a/scripts/resource_doc_baseline/parity_allowlist.json +++ b/scripts/resource_doc_baseline/parity_allowlist.json @@ -763,14 +763,6 @@ "lift_digest": "1121e061bb57d86b7b28c331cfd46382f6bc01b163301a62871d5c95b0240eab", "http4s_digest": "1216685612894e081ee8c9fa73e16bec14351fa08675b4c15c7f5043a3578c03" }, - { - "version": "v4_0_0", - "endpoint": "deleteExplicitCounterparty", - "field": "requestUrl", - "reason": "COUNTERPARTY_ID -> COUNTERPARTY_ID_PARAM rename, same shape.", - "lift_digest": "921d75d9e3f3f8829417e01b547c83bb66305f39e6125d07641d70228ba12762", - "http4s_digest": "f17aeac76f848755b702722badaf8024590ced0c796cdf6467cc8f3ca0cabf2e" - }, { "version": "v4_0_0", "endpoint": "deleteUser", @@ -803,14 +795,6 @@ "lift_digest": "88d3999f8d4a15b904b69b597c770539efcee05cf149c0eb47cba88e4134bce8", "http4s_digest": "ab7e41da4bb607ed2e1f5008352a6914ce993c1bb231d21e183bb53e78988da7" }, - { - "version": "v4_0_0", - "endpoint": "getCounterpartyByIdForAnyAccount", - "field": "requestUrl", - "reason": "COUNTERPARTY_ID_PARAM rename.", - "lift_digest": "10cc23745c9f2e446f7128aebff01d4cd79e7390299d1ff9d53782c8e199a0c8", - "http4s_digest": "ca07ee2eaee48c958711ac6e697032ac41035a9fdfe4ee4cdc71ff6ea4412cb9" - }, { "version": "v4_0_0", "endpoint": "getDynamicResourceDoc", @@ -819,14 +803,6 @@ "lift_digest": "1121e061bb57d86b7b28c331cfd46382f6bc01b163301a62871d5c95b0240eab", "http4s_digest": "1216685612894e081ee8c9fa73e16bec14351fa08675b4c15c7f5043a3578c03" }, - { - "version": "v4_0_0", - "endpoint": "getExplicitCounterpartyById", - "field": "requestUrl", - "reason": "EXPLICIT_COUNTERPARTY_ID rename.", - "lift_digest": "921d75d9e3f3f8829417e01b547c83bb66305f39e6125d07641d70228ba12762", - "http4s_digest": "51b5c4487bf594005cbd5895f0fa7f8f2ef4ed246386e9cd2fb154c386d5289b" - }, { "version": "v4_0_0", "endpoint": "getFirehoseAccountsAtOneBank", @@ -939,14 +915,6 @@ "lift_digest": "172ecd180e7c87fd9080d231736f8ea1e5d130fdc9224adb017d69494edba066", "http4s_digest": "5f61128bd66efed0f37498698f7819d75cc2dcbb4501d09e96fd74769eeb4c55" }, - { - "version": "v6_0_0", - "endpoint": "createCounterpartyAttribute", - "field": "requestUrl", - "reason": "COUNTERPARTY_ID_PARAM rename.", - "lift_digest": "4afc1baca0551e1cb89611a42c0d7652e3040468728f465d9061052414e6e1a9", - "http4s_digest": "5f2aedc26f7688fd913b6f9dffe12c6858fb3814c7515530861a1613afb8a5b5" - }, { "version": "v6_0_0", "endpoint": "createPersonalDataField", @@ -971,14 +939,6 @@ "lift_digest": "f171234e229c0a8c5636773ff0eef555a86cc86cbce6dd89854d72157d56c76a", "http4s_digest": "4f6ba60ae9bfce501646d1311bfb1d544d3ecbf02b4b5e522e8da293d3407c04" }, - { - "version": "v6_0_0", - "endpoint": "deleteCounterpartyAttribute", - "field": "requestUrl", - "reason": "COUNTERPARTY_ID_PARAM rename.", - "lift_digest": "6be19ba2409dbba4ae9f6ed783f5a2068645dd189761c83af8cd0d9f107a22f4", - "http4s_digest": "2cb204a9cd0b15471f6fdbe274209d3f4cce2a4d016f0fd8526b0b24fa640d8a" - }, { "version": "v6_0_0", "endpoint": "deletePersonalDataField", @@ -1059,14 +1019,6 @@ "lift_digest": "9940ac8e6f7dbf5a4e4e5d8a65635722e8ce8839f7954ad39f99c1eee3f12865", "http4s_digest": "b8f6c80d7d9aab5c1954d93d78e4e9caaf55bbba86cedc57b614c7350f5d8fda" }, - { - "version": "v6_0_0", - "endpoint": "getAllCounterpartyAttributes", - "field": "requestUrl", - "reason": "COUNTERPARTY_ID_PARAM rename.", - "lift_digest": "4afc1baca0551e1cb89611a42c0d7652e3040468728f465d9061052414e6e1a9", - "http4s_digest": "5f2aedc26f7688fd913b6f9dffe12c6858fb3814c7515530861a1613afb8a5b5" - }, { "version": "v6_0_0", "endpoint": "getAllProductsV600", @@ -1099,14 +1051,6 @@ "lift_digest": "f41cdd1dbacdfd52a114a2dc76487367b4b20746184d32d3b0709bd858b8363d", "http4s_digest": "835e4890e3775490f00f47ca65a16e63acc63d6cf1961976712b585b643e54e2" }, - { - "version": "v6_0_0", - "endpoint": "getCounterpartyAttributeById", - "field": "requestUrl", - "reason": "COUNTERPARTY_ID_PARAM rename.", - "lift_digest": "6be19ba2409dbba4ae9f6ed783f5a2068645dd189761c83af8cd0d9f107a22f4", - "http4s_digest": "2cb204a9cd0b15471f6fdbe274209d3f4cce2a4d016f0fd8526b0b24fa640d8a" - }, { "version": "v6_0_0", "endpoint": "getPersonalDataFieldById", @@ -1187,14 +1131,6 @@ "lift_digest": "dca0922381fb4c5f1fb34ab5a531d440b068f2170fd933c3c2eeddb4c52eaf94", "http4s_digest": "f0bea922b31c331bae138d58990bf31a6bc5ca0099055f4cff4fdaf62c0a6263" }, - { - "version": "v6_0_0", - "endpoint": "updateCounterpartyAttribute", - "field": "requestUrl", - "reason": "COUNTERPARTY_ID_PARAM rename.", - "lift_digest": "6be19ba2409dbba4ae9f6ed783f5a2068645dd189761c83af8cd0d9f107a22f4", - "http4s_digest": "2cb204a9cd0b15471f6fdbe274209d3f4cce2a4d016f0fd8526b0b24fa640d8a" - }, { "version": "v6_0_0", "endpoint": "updatePersonalDataField", @@ -1639,9 +1575,9 @@ "version": "v4_0_0", "endpoint": "answerTransactionRequestChallenge", "field": "errorResponseBodies", - "reason": "Adds ChallengeNotAddressedToCaller (OBP-40063): a payment challenge is answered by the person the payment is for, so an agent acting under a Consent is refused rather than allowed to relay a code it was never sent. See ON_BEHALF_OF_USER_ID_PLAN.md Decision 9.", + "reason": "Adds ChallengeNotAddressedToCaller (OBP-40063): a payment challenge is answered by the person the payment is for, so an agent acting under a Consent is refused rather than allowed to relay a code it was never sent. See ON_BEHALF_OF_USER_ID_PLAN.md Decision 9. Lists InvalidTransactionRequestId, returned when the transaction request does not involve the BANK_ID / ACCOUNT_ID in the URL.", "lift_digest": "13648f17562c1654881963b4978024f82e1edcdb92729397a2b015b291939ddb", - "http4s_digest": "c584fa1579db6b30ed7c21cf3dc7da57f50dcad7abba332ea1a58dd4c37808c4" + "http4s_digest": "82c6ce31f384147541e77f70a52e17ab45e7230c8543a92eaf9df446070317b2" }, { "version": "v4_0_0", @@ -1695,9 +1631,9 @@ "version": "v4_0_0", "endpoint": "createTransactionRequestRefund", "field": "errorResponseBodies", - "reason": "Adds PaymentChallengeHasNoOnBehalfOfUser (OBP-40064): a payment needing Strong Customer Authentication is refused when no person can be determined to address the challenge to, instead of being parked behind a challenge nobody can answer. See ON_BEHALF_OF_USER_ID_PLAN.md Decision 9.", + "reason": "Adds PaymentChallengeHasNoOnBehalfOfUser (OBP-40064): a payment needing Strong Customer Authentication is refused when no person can be determined to address the challenge to, instead of being parked behind a challenge nobody can answer. See ON_BEHALF_OF_USER_ID_PLAN.md Decision 9. Lists CounterpartyNotFoundByCounterpartyId (the counterparty does not belong to the account in the URL), RefundTransactionNotWithThisParty (the refunded transaction was not a payment between this account and the named party, in the right direction) and RefundedTransaction (the transaction was already refunded).", "lift_digest": "4eac30b6b708ad9f30e688ef1327a01ff7f4280f982f77063851c9483e3179c2", - "http4s_digest": "36b31730de78f93aa41f09da7fe44eff78fe76dac497497e7562b84454a2e3b0" + "http4s_digest": "d60c90fc7e51d9f5b050dd3ca31d0c4176292041c962433830f47f8767270800" }, { "version": "v4_0_0", @@ -1815,17 +1751,17 @@ "version": "v5_1_0", "endpoint": "createConsumer", "field": "description", - "reason": "Adds a paragraph pointing the developer of an app the installation runs itself (Portal, API Manager, Opey, OBP-Sentinel) to Platform Apps, so its Consumer can be granted Roles as Scopes.", + "reason": "Adds a paragraph pointing the developer of an app the installation runs itself (Portal, API Manager, Opey, OBP-Sentinel) to Platform Apps, so its Consumer can be granted Roles as Scopes. Adds the redirect URL rules, this instance's redirect URL host list, and a link to the Redirect URL glossary entry.", "lift_digest": "7e639520852912f5fbb9938d9d3f65943054eb8a158dfa7105416a43870d3ce4", - "http4s_digest": "983ad12ac6c61fc5637ee32d4fbc95592ed259c2588b8b50feab0376fec554a6" + "http4s_digest": "0dc9ec5c88cccd750dc082dad3a38334251bfcd2c4bdb1e1b48ac3076f91aed8" }, { "version": "v5_1_0", "endpoint": "createMyConsumer", "field": "description", - "reason": "Adds a paragraph pointing the developer of an app the installation runs itself (Portal, API Manager, Opey, OBP-Sentinel) to Platform Apps, so its Consumer can be granted Roles as Scopes.", + "reason": "Adds a paragraph pointing the developer of an app the installation runs itself (Portal, API Manager, Opey, OBP-Sentinel) to Platform Apps, so its Consumer can be granted Roles as Scopes. Adds the redirect URL rules, this instance's redirect URL host list, and a link to the Redirect URL glossary entry.", "lift_digest": "bba5099d62c01e4fd03073a2c54ef3f5669d0cb0f756d8c2556dd48c9dc8cbc2", - "http4s_digest": "fb85fa76f84dab2e7a328d65e38b2d1ecf9eabe90ca99efdd7e8d24ff9bcd152" + "http4s_digest": "ff968d07d614393257e9f40efd3293e0c4f1872e86ec07955e1429756b8b364c" }, { "version": "v3_1_0", @@ -1946,6 +1882,254 @@ "reason": "Lists the accepted attribute types from AttributeTypeDocs.typeFieldDescription, which adds DECIMAL and BOOLEAN (new enum values) to the four the Lift text listed and says when to use DECIMAL.", "lift_digest": "d2f72f200b8c01cb57f1e07c608a160c125d3e210f9282fbbfa532c72f802248", "http4s_digest": "789c5598a7af5691282a4192dc5d38199bb068928a392b08314aab3b96d5b6af" + }, + { + "version": "v2_1_0", + "endpoint": "updateConsumerRedirectUrl", + "field": "errorResponseBodies", + "reason": "Adds RedirectUrlHostNotAllowed (OBP-20109) for instances that set redirect_url_allowed_hosts, alongside InvalidRedirectUrl (OBP-20108): the redirect URL is checked against the redirect URL rules before it is stored.", + "lift_digest": "f06cd9a2114625e9b6a37b8e57fa91e7bdb6586b9899fcd9265713aed3e873a4", + "http4s_digest": "b1e42b699ef095f7b2a523062366197eee3538c96068563f1bd25f0197559e33" + }, + { + "version": "v2_2_0", + "endpoint": "createConsumer", + "field": "errorResponseBodies", + "reason": "Adds RedirectUrlHostNotAllowed (OBP-20109) for instances that set redirect_url_allowed_hosts, alongside InvalidRedirectUrl (OBP-20108): the redirect URL is checked against the redirect URL rules before it is stored.", + "lift_digest": "647479a642a49ee3a80a4bfc28ab2ae3413796d26a3eecbc48411d6e05620ad5", + "http4s_digest": "f1a3eab881d039a14b768fca036adc44759cc99d416fc176c8eea09829037ccf" + }, + { + "version": "v2_2_0", + "endpoint": "createConsumer", + "field": "exampleRequestBody", + "reason": "The example redirect URL is now a valid https callback; the old placeholder would be rejected by the redirect URL rules.", + "lift_digest": "7b5796ab8e816b80f886a5d44a8414394170d7e2d61ab2ed6edb6ae4a58841e0", + "http4s_digest": "28fbe0a0ed9a7eb4e229b285c1715bc32b77a0485d6db6c9a3661ea037859a8b" + }, + { + "version": "v2_2_0", + "endpoint": "createConsumer", + "field": "successResponseBody", + "reason": "The example redirect URL is now a valid https callback; the old placeholder would be rejected by the redirect URL rules.", + "lift_digest": "4cb0d642b4a86b86be87cad67a311c77f1d68aacafb957b2e4e83d23ae9695fb", + "http4s_digest": "5914176f0c140e0e56de59f39988dd4bd8312a0c873aa816729a12d5037ae936" + }, + { + "version": "v4_0_0", + "endpoint": "createConsumer", + "field": "errorResponseBodies", + "reason": "Adds RedirectUrlHostNotAllowed (OBP-20109) for instances that set redirect_url_allowed_hosts, alongside InvalidRedirectUrl (OBP-20108): the redirect URL is checked against the redirect URL rules before it is stored.", + "lift_digest": "647479a642a49ee3a80a4bfc28ab2ae3413796d26a3eecbc48411d6e05620ad5", + "http4s_digest": "f1a3eab881d039a14b768fca036adc44759cc99d416fc176c8eea09829037ccf" + }, + { + "version": "v4_0_0", + "endpoint": "createConsumer", + "field": "exampleRequestBody", + "reason": "The example redirect URL is now a valid https callback; the old placeholder would be rejected by the redirect URL rules.", + "lift_digest": "bedf35738b2bdbadfd0cf52925b59047394f1479aaf041401e689921a15d5da5", + "http4s_digest": "64bb412d6630509cccfda9d1cba36da072ace8e189a6798811648b1c2267aaa7" + }, + { + "version": "v5_1_0", + "endpoint": "createConsumer", + "field": "errorResponseBodies", + "reason": "Adds RedirectUrlHostNotAllowed (OBP-20109) for instances that set redirect_url_allowed_hosts, alongside InvalidRedirectUrl (OBP-20108): the redirect URL is checked against the redirect URL rules before it is stored.", + "lift_digest": "647479a642a49ee3a80a4bfc28ab2ae3413796d26a3eecbc48411d6e05620ad5", + "http4s_digest": "f1a3eab881d039a14b768fca036adc44759cc99d416fc176c8eea09829037ccf" + }, + { + "version": "v5_1_0", + "endpoint": "createMyConsumer", + "field": "errorResponseBodies", + "reason": "Adds RedirectUrlHostNotAllowed (OBP-20109) for instances that set redirect_url_allowed_hosts, alongside InvalidRedirectUrl (OBP-20108): the redirect URL is checked against the redirect URL rules before it is stored.", + "lift_digest": "ab6b9a504c9dafbcc134976d9f0b976cbe222c9054b9c811c5570adfb79ae6ce", + "http4s_digest": "8aea0f733a9dab1db64f8bcdc5e240c17467c6eee80d468b48c76dd8f7be81d3" + }, + { + "version": "v5_1_0", + "endpoint": "createConsumerDynamicRegistration", + "field": "errorResponseBodies", + "reason": "Adds InvalidRedirectUrl (OBP-20108): the redirect URL is now checked against the redirect URL rules (https, http only for loopback, reverse-domain app schemes; no wildcard, user information or fragment) before it is stored.", + "lift_digest": "4e74e64b929f5a2a290113b4f8bfabd520ea39ad6fa4fe1a498fe76bbb805631", + "http4s_digest": "64c74c1413b68ad5553717d6ee442ed730ff08a383c419d597c5ddda9b711b72" + }, + { + "version": "v5_1_0", + "endpoint": "updateConsumerRedirectURL", + "field": "errorResponseBodies", + "reason": "Adds RedirectUrlHostNotAllowed (OBP-20109) for instances that set redirect_url_allowed_hosts, alongside InvalidRedirectUrl (OBP-20108): the redirect URL is checked against the redirect URL rules before it is stored.", + "lift_digest": "f06cd9a2114625e9b6a37b8e57fa91e7bdb6586b9899fcd9265713aed3e873a4", + "http4s_digest": "b1e42b699ef095f7b2a523062366197eee3538c96068563f1bd25f0197559e33" + }, + { + "version": "v6_0_0", + "endpoint": "getOidcClient", + "field": "description", + "reason": "States that redirect_uris leaves out stored entries that break the redirect URL rules, which the endpoint now filters, and links the Redirect URL glossary entry.", + "lift_digest": "49ddd5f6ec985292ee48802cd18e479c5a1b46fee368ce0d06a653e07eb65e90", + "http4s_digest": "afb1da33f039efe8cff6de2d718c11e552434fb08f7e41a66d82a26d07be0de6" + }, + { + "version": "v6_0_0", + "endpoint": "verifyOidcClient", + "field": "description", + "reason": "States that redirect_uris leaves out stored entries that break the redirect URL rules, which the endpoint now filters, and links the Redirect URL glossary entry.", + "lift_digest": "120f4c6ed1083567d078a7afbb5c2e97d8de69e68e7314166c05e3afcdf50ecd", + "http4s_digest": "670c0479be7d2aa3c8beb18d2e1708c3ec7e67b2a1b625018b07a89c770fe962" + }, + { + "version": "v5_1_0", + "endpoint": "updateConsumerRedirectURL", + "field": "description", + "reason": "Adds the redirect URL rules, this instance's redirect URL host list, and a link to the Redirect URL glossary entry.", + "lift_digest": "d2319e98d44314c5142a17c76318478bd3f2e88a41c560f30fd99ebde75a6b9b", + "http4s_digest": "6ae0abfeccdb9959fc3efe679b4a279e6aefb89f584a3d8d0f025620f03329f9" + }, + { + "version": "v5_1_0", + "endpoint": "waitingForGodot", + "field": "description", + "reason": "States that authentication and the Role CanGetWaitingForGodot are required, and the delay cap on this instance (waiting_for_godot_max_sleep_in_milliseconds); negative or unreadable values count as 0.", + "lift_digest": "ecc556ba40db508705a57cb4f877b21bbb12a30a3fb48193c2f93da9ea4295ea", + "http4s_digest": "4aa1ed75cf6fb788d6f268f1693820ab5eb3dd20ea771cfb36d798abbb5371f9" + }, + { + "version": "v5_1_0", + "endpoint": "waitingForGodot", + "field": "errorResponseBodies", + "reason": "The endpoint now requires authentication and the Role CanGetWaitingForGodot (it was anonymous and could tie up the shared thread pool); MandatoryPropertyIsNotSet was never raised.", + "lift_digest": "6ba561b556cbd54b3708ddc50db90e03301fda0b10101c168c80e590bcbdeb8c", + "http4s_digest": "cb07b8a8074c71a21a64f9030e412d57858383e4f503c2444c6c7000be5f4030" + }, + { + "version": "v1_4_0", + "endpoint": "getCrmEvents", + "field": "description", + "reason": "The description was empty; it now says the endpoint returns every customer's CRM events at the bank and needs authentication and the Role CanGetCrmEvents.", + "lift_digest": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "http4s_digest": "dec619a40558e96f890969e02eb8f036f8c2427d43139f8f02fec670b5762957" + }, + { + "version": "v1_4_0", + "endpoint": "getCrmEvents", + "field": "errorResponseBodies", + "reason": "The endpoint now requires the bank-level Role CanGetCrmEvents; it returned every customer's CRM events to any logged-in User (the 2015 code was marked TODO Require Role).", + "lift_digest": "17b115e7d973045ce988c14715fa934e8dbb506b02316d0736dbc19c82ce75b3", + "http4s_digest": "26b2f283fe3019f4c20bec2d8666686efcc376f0250fcfe43933a1c4236c2747" + }, + { + "version": "v2_0_0", + "endpoint": "getTransactionTypes", + "field": "errorResponseBodies", + "reason": "Lists AuthenticatedUserIsRequired so the middleware requires a login when apiOptions.getTransactionTypesIsPublic is false, as the Lift version did; the ResourceDoc drops it again when the prop is true.", + "lift_digest": "3bdcd0c958443de6b8a40314fb0e25bb976e6a734c73b4877302491756cb2ab9", + "http4s_digest": "266858573788363b20c268a76fb993e29a82e884667ec1a8f2ec8d27a9b88700" + }, + { + "version": "v4_0_0", + "endpoint": "createStandingOrder", + "field": "errorResponseBodies", + "reason": "Lists CounterpartyNotFoundByCounterpartyId, returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.", + "lift_digest": "06a05f5082af05f86fb2d03f146a4a0d5f509405599781682198421383d24b1d", + "http4s_digest": "00a4a41b6dd6180c22589c39c7f504812330325232d1500fa4e1de0db4d649f1" + }, + { + "version": "v4_0_0", + "endpoint": "createStandingOrderManagement", + "field": "errorResponseBodies", + "reason": "Lists CounterpartyNotFoundByCounterpartyId, returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.", + "lift_digest": "56a8a987f7348394c50e8f41e8cd0490b0ce68b266f898088c19334fa6b9e3a1", + "http4s_digest": "b716ea78d9cd1c02ef8aadef73ae10bb8870c0072b6156de4bb4017d04b2cedb" + }, + { + "version": "v4_0_0", + "endpoint": "deleteCounterpartyForAnyAccount", + "field": "errorResponseBodies", + "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check), returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.", + "lift_digest": "74e48268b3b2dcc8120085167677f93e918c82246f2974bf90dbe4e8fa1ee761", + "http4s_digest": "5b86622b4bf6256e1840f581347312adad4de3cc6715d9f1c89a876827ca8026" + }, + { + "version": "v4_0_0", + "endpoint": "deleteExplicitCounterparty", + "field": "errorResponseBodies", + "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check), returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.", + "lift_digest": "ad616f0980b99d4e23758a08c4983d6e05fd48e3ee6160832a4058bf0cba0ac5", + "http4s_digest": "dec64daf6eb1d796825733dc5a90de2abcb947d14fbc83d6ce8d8409daf83501" + }, + { + "version": "v4_0_0", + "endpoint": "getCounterpartyByIdForAnyAccount", + "field": "errorResponseBodies", + "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check), returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.", + "lift_digest": "049483db4c37fe0fed677d64c700f3120cc5683a749b8316c47cec7f7918409b", + "http4s_digest": "0237a2b1b407cb784cb0c9f09c5cbf503210e00ebe9866a351f41d18e77b2a2a" + }, + { + "version": "v4_0_0", + "endpoint": "getExplicitCounterpartyById", + "field": "errorResponseBodies", + "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check), returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.", + "lift_digest": "86bc4572566bcf1dd26b36429bd2e18bcea94b288b76100f9c842a383f6ca8a6", + "http4s_digest": "1227ef34c7cabd8837842965f7b4997fa41219f5bd6b41d8503b277c12d24d3a" + }, + { + "version": "v6_0_0", + "endpoint": "createCounterpartyAttribute", + "field": "errorResponseBodies", + "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check), returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.", + "lift_digest": "8b9e9294a47baff632975336cec3da4c29f3ca3b8398ec5875e9b5d1712f0140", + "http4s_digest": "8688a59895b39a1fa023ea1dd7713a6f32154a76d772736dbef5d05d970ab5e4" + }, + { + "version": "v6_0_0", + "endpoint": "getAllCounterpartyAttributes", + "field": "errorResponseBodies", + "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check), returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.", + "lift_digest": "12ad62cb53de1ed64537da486e94cc24fab901e301123e5f601fcd17fade09b3", + "http4s_digest": "956f30cff3ef2d5eeb625620e71e67ff08f6d9934d9d7e36aeb5c7e9f8f465fe" + }, + { + "version": "v6_0_0", + "endpoint": "deleteCounterpartyAttribute", + "field": "errorResponseBodies", + "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check: the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL) and CounterpartyAttributeNotFound (the attribute does not belong to the COUNTERPARTY_ID in the URL).", + "lift_digest": "12ad62cb53de1ed64537da486e94cc24fab901e301123e5f601fcd17fade09b3", + "http4s_digest": "ad3a9a07f9f61f02744348beab43a6723b64032ad869dfefd4e2c8f8cd92b752" + }, + { + "version": "v6_0_0", + "endpoint": "getCounterpartyAttributeById", + "field": "errorResponseBodies", + "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check: the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL) and CounterpartyAttributeNotFound (the attribute does not belong to the COUNTERPARTY_ID in the URL).", + "lift_digest": "12ad62cb53de1ed64537da486e94cc24fab901e301123e5f601fcd17fade09b3", + "http4s_digest": "ad3a9a07f9f61f02744348beab43a6723b64032ad869dfefd4e2c8f8cd92b752" + }, + { + "version": "v6_0_0", + "endpoint": "updateCounterpartyAttribute", + "field": "errorResponseBodies", + "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check: the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL) and CounterpartyAttributeNotFound (the attribute does not belong to the COUNTERPARTY_ID in the URL).", + "lift_digest": "8b9e9294a47baff632975336cec3da4c29f3ca3b8398ec5875e9b5d1712f0140", + "http4s_digest": "4776e3f23e3c01fc6a4a433289d9d2a1241b60e92eb44145cf4218ed5b6d1198" + }, + { + "version": "v2_1_0", + "endpoint": "createTransactionType", + "field": "description", + "reason": "Always says User Authentication is Required; it used apiOptions.getTransactionTypesIsPublic (the read setting) un-negated, so with that prop false the docs called login optional for this create endpoint.", + "lift_digest": "4da376be772a5df5ca18eb378056a8b6cd06359b9e1b716e39590cddc035bd6b", + "http4s_digest": "d11d7022ff409e6f65b2d8fe4f56b8dd54bfd7744dd946806347c91d3ed355b6" + }, + { + "version": "v2_0_0", + "endpoint": "createAccount", + "field": "errorResponseBodies", + "reason": "Adds AccountIdAlreadyExists: an existing ACCOUNT_ID is now refused, where it used to return the existing account and make the caller one of its holders.", + "lift_digest": "30b24bdbcdb523f00b5e4655fbdb91d8648ab84ee92f1f2292008816b8e31012", + "http4s_digest": "c48cc1204d4ea66a5f131c599ace7f8d502c91ce16a36bf0e507e06b915b8055" } ] }