From d29846873dc84d37377fb6cdea14351a3aa2203e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 23 Jun 2025 08:37:51 +0200 Subject: [PATCH 01/33] feature/Remove sensitive data from error massage --- obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 0a50a166a..2679dc0a0 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -112,10 +112,11 @@ object BerlinGroupCheck extends MdcLoggable { val serialNumber = certificate.getSerialNumber.toString if(parsed.keyId.sn != serialNumber) { logger.debug(s"Serial number from certificate: $serialNumber") + logger.debug(s"keyId.SN:${parsed.keyId.sn}") Some( ( fullBoxOrException( - Empty ~> APIFailureNewStyle(s"${ErrorMessages.InvalidSignatureHeader}keyId.SN: ${parsed.keyId.sn} does not match the serial number from certificate: $serialNumber", 400, forwardResult._2.map(_.toLight)) + Empty ~> APIFailureNewStyle(s"${ErrorMessages.InvalidSignatureHeader}keyId.SN does not match the serial number from certificate", 400, forwardResult._2.map(_.toLight)) ), forwardResult._2 ) From 52a3c84bad9f070fa9a00275346fd9877ff747da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 26 Jun 2025 09:11:28 +0200 Subject: [PATCH 02/33] feature/"name" key in GET Accounts --- .../api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala index e3c7a39ee..31add19b0 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala @@ -423,7 +423,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ resourceId = bankAccount.accountId.value, iban = iBan, currency = bankAccount.currency, - name = if(bankAccount.name.isBlank) None else Some(bankAccount.name), + name = if(APIUtil.getPropsAsBoolValue("BG_v1312_show_account_name", defaultValue = true)) Some(bankAccount.name) else None, cashAccountType = bankAccount.accountType, product = bankAccount.accountType, _links = AccountDetailsLinksJsonV13( From 05bf4019a2154df3d598c31809c80be57b4575bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 30 Jun 2025 14:55:13 +0200 Subject: [PATCH 03/33] =?UTF-8?q?feature/OBP=20API=20=E2=80=93=20Docker=20?= =?UTF-8?q?&=20Docker=20Compose=20Setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/Dockerfile | 18 ++++++ docker/README.md | 96 ++++++++++++++++++++++++++++++ docker/docker-compose.override.yml | 7 +++ docker/docker-compose.yml | 14 +++++ docker/entrypoint.sh | 9 +++ 5 files changed, 144 insertions(+) create mode 100644 docker/Dockerfile create mode 100644 docker/README.md create mode 100644 docker/docker-compose.override.yml create mode 100644 docker/docker-compose.yml create mode 100755 docker/entrypoint.sh diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 000000000..53a999d1d --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,18 @@ +FROM maven:3.9.6-eclipse-temurin-17 + +WORKDIR /app + +# Copy all project files into container +COPY . . + +EXPOSE 8080 + +# Build the project, skip tests to speed up +RUN mvn install -pl .,obp-commons -am -DskipTests + +# Copy entrypoint script that runs mvn with needed JVM flags +COPY docker/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# Use script as entrypoint +CMD ["/app/entrypoint.sh"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 000000000..1c46f09e0 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,96 @@ +## OBP API – Docker & Docker Compose Setup + +This project uses Docker and Docker Compose to run the **OBP API** service with Maven and Jetty. + +- Java 17 with reflection workaround +- Connects to your local Postgres using `host.docker.internal` +- Supports separate dev & prod setups + +--- + +## How to use + +> **Make sure you have Docker and Docker Compose installed.** + +### Set up the database connection + +Edit your `default.properties` (or similar config file): + +```properties +db.url=jdbc:postgresql://host.docker.internal:5432/YOUR_DB_NAME?user=YOUR_DB_USER&password=YOUR_DB_PASSWORD +```` + +> Use `host.docker.internal` so the container can reach your local database. + +--- + +### Build & run (production mode) + +Build the Docker image and run the container: + +```bash +docker-compose up --build +``` + +The service will be available at [http://localhost:8080](http://localhost:8080). + +--- + +## Development tips + +For live code updates without rebuilding: + +* Use the provided `docker-compose.override.yml` which mounts only: + + ```yaml + volumes: + - ../obp-api:/app/obp-api + - ../obp-commons:/app/obp-commons + ``` +* This keeps other built files (like `entrypoint.sh`) intact. +* Avoid mounting the full `../:/app` because it overwrites the built image. + +--- + +## Useful commands + +Rebuild the image and restart: + +```bash +docker-compose up --build +``` + +Stop the container: + +```bash +docker-compose down +``` + +--- + +## Before first run + +Make sure your entrypoint script is executable: + +```bash +chmod +x docker/entrypoint.sh +``` + +--- + +## Notes + +* The container uses `MAVEN_OPTS` to pass JVM `--add-opens` flags needed by Lift. +* In production, avoid volume mounts for better performance and consistency. + +--- + +That’s it — now you can run: + +```bash +docker-compose up --build +``` + +and start coding! + +``` \ No newline at end of file diff --git a/docker/docker-compose.override.yml b/docker/docker-compose.override.yml new file mode 100644 index 000000000..80e973a2c --- /dev/null +++ b/docker/docker-compose.override.yml @@ -0,0 +1,7 @@ +version: "3.8" + +services: + obp-api: + volumes: + - ../obp-api:/app/obp-api + - ../obp-commons:/app/obp-commons diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 000000000..ca4eda42a --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.8" + +services: + obp-api: + build: + context: .. + dockerfile: docker/Dockerfile + ports: + - "8080:8080" + extra_hosts: + # Connect to local Postgres on the host + # In your config file: + # db.url=jdbc:postgresql://host.docker.internal:5432/YOUR_DB?user=YOUR_DB_USER&password=YOUR_DB_PASSWORD + - "host.docker.internal:host-gateway" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 000000000..b35048478 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -e + +export MAVEN_OPTS="-Xss128m \ + --add-opens=java.base/java.util.jar=ALL-UNNAMED \ + --add-opens=java.base/java.lang=ALL-UNNAMED \ + --add-opens=java.base/java.lang.reflect=ALL-UNNAMED" + +exec mvn jetty:run -pl obp-api From f4fa89b5bf13a4161b0acb02b11f1b11234b23a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 2 Jul 2025 12:00:16 +0200 Subject: [PATCH 04/33] feature/Improve Get Consents endpoint --- .../main/scala/code/api/util/APIUtil.scala | 10 +-- .../main/scala/code/api/util/OBPParam.scala | 1 + .../scala/code/consent/MappedConsent.scala | 68 ++++++++++++++++++- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index a80c555dc..2d3157abb 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1138,6 +1138,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ value <- tryo(values.head.toBoolean) ?~! FilterIsDeletedFormatError deleted = OBPIsDeleted(value) } yield deleted + case "sort_by" => Full(OBPSortBy(values.head)) case "status" => Full(OBPStatus(values.head)) case "consumer_id" => Full(OBPConsumerId(values.head)) case "azp" => Full(OBPAzp(values.head)) @@ -1180,6 +1181,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def createQueriesByHttpParams(httpParams: List[HTTPParam]): Box[List[OBPQueryParam]] = { for{ + sortBy <- getHttpParamValuesByName(httpParams, "sort_by") sortDirection <- getSortDirection(httpParams) fromDate <- getFromDate(httpParams) toDate <- getToDate(httpParams) @@ -1226,10 +1228,9 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ * */ //val sortBy = json.header("obp_sort_by") - val sortBy = None - val ordering = OBPOrdering(sortBy, sortDirection) + val ordering = OBPOrdering(None, sortDirection) //This guarantee the order - List(limit, offset, ordering, fromDate, toDate, + List(limit, offset, ordering, sortBy, fromDate, toDate, anon, status, consumerId, azp, iss, consentId, userId, url, appName, implementedByPartialFunction, implementedInVersion, verb, correlationId, duration, excludeAppNames, excludeUrlPattern, excludeImplementedByPartialfunctions, includeAppNames, includeUrlPattern, includeImplementedByPartialfunctions, @@ -1259,6 +1260,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ */ def createHttpParamsByUrl(httpRequestUrl: String): Box[List[HTTPParam]] = { val sleep = getHttpRequestUrlParam(httpRequestUrl,"sleep") + val sortBy = getHttpRequestUrlParam(httpRequestUrl,"sort_by") val sortDirection = getHttpRequestUrlParam(httpRequestUrl,"sort_direction") val fromDate = getHttpRequestUrlParam(httpRequestUrl,"from_date") val toDate = getHttpRequestUrlParam(httpRequestUrl,"to_date") @@ -1300,7 +1302,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ val connectorName = getHttpRequestUrlParam(httpRequestUrl, "connector_name") Full(List( - HTTPParam("sort_direction",sortDirection), HTTPParam("from_date",fromDate), HTTPParam("to_date", toDate), HTTPParam("limit",limit), HTTPParam("offset",offset), + HTTPParam("sort_by",sortBy), HTTPParam("sort_direction",sortDirection), HTTPParam("from_date",fromDate), HTTPParam("to_date", toDate), HTTPParam("limit",limit), HTTPParam("offset",offset), HTTPParam("anon", anon), HTTPParam("status", status), HTTPParam("consumer_id", consumerId), HTTPParam("azp", azp), HTTPParam("iss", iss), HTTPParam("consent_id", consentId), HTTPParam("user_id", userId), HTTPParam("url", url), HTTPParam("app_name", appName), HTTPParam("implemented_by_partial_function",implementedByPartialFunction), HTTPParam("implemented_in_version",implementedInVersion), HTTPParam("verb", verb), HTTPParam("correlation_id", correlationId), HTTPParam("duration", duration), HTTPParam("exclude_app_names", excludeAppNames), diff --git a/obp-api/src/main/scala/code/api/util/OBPParam.scala b/obp-api/src/main/scala/code/api/util/OBPParam.scala index b81b18a86..49bd62193 100644 --- a/obp-api/src/main/scala/code/api/util/OBPParam.scala +++ b/obp-api/src/main/scala/code/api/util/OBPParam.scala @@ -26,6 +26,7 @@ case class OBPFromDate(value: Date) extends OBPQueryParam case class OBPToDate(value: Date) extends OBPQueryParam case class OBPOrdering(field: Option[String], order: OBPOrder) extends OBPQueryParam case class OBPConsumerId(value: String) extends OBPQueryParam +case class OBPSortBy(value: String) extends OBPQueryParam case class OBPAzp(value: String) extends OBPQueryParam case class OBPIss(value: String) extends OBPQueryParam case class OBPConsentId(value: String) extends OBPQueryParam diff --git a/obp-api/src/main/scala/code/consent/MappedConsent.scala b/obp-api/src/main/scala/code/consent/MappedConsent.scala index 072b1d50b..2514d9df5 100644 --- a/obp-api/src/main/scala/code/consent/MappedConsent.scala +++ b/obp-api/src/main/scala/code/consent/MappedConsent.scala @@ -1,7 +1,7 @@ package code.consent import java.util.Date -import code.api.util.{APIUtil, Consent, ErrorMessages, OBPBankId, OBPConsentId, OBPConsumerId, OBPLimit, OBPOffset, OBPQueryParam, OBPStatus, OBPUserId, SecureRandomUtil} +import code.api.util.{APIUtil, Consent, ErrorMessages, OBPBankId, OBPConsentId, OBPConsumerId, OBPLimit, OBPOffset, OBPQueryParam, OBPSortBy, OBPStatus, OBPUserId, SecureRandomUtil} import code.consent.ConsentStatus.ConsentStatus import code.model.Consumer import code.util.MappedUUID @@ -73,7 +73,23 @@ object MappedConsentProvider extends ConsentProvider { val consentId = queryParams.collectFirst { case OBPConsentId(value) => By(MappedConsent.mConsentId, value) } val userId = queryParams.collectFirst { case OBPUserId(value) => By(MappedConsent.mUserId, value) } val status = queryParams.collectFirst { - case OBPStatus(value) => ByList(MappedConsent.mStatus, List(value.toLowerCase(), value.toUpperCase())) + case OBPStatus(value) => + // Split the comma-separated string into a List, and trim whitespace from each element + val statuses: List[String] = value.split(",").toList.map(_.trim) + + // For each distinct status: + // - create both lowercase ancheckIsLockedd uppercase versions + // - flatten the resulting list of lists into a single list + // - remove duplicates from the final list + val distinctLowerAndUpperCaseStatuses: List[String] = + statuses.distinct // Remove duplicates (case-sensitive) + .flatMap(s => List( // For each element, generate: + s.toLowerCase, // - lowercase version + s.toUpperCase // - uppercase version + )) + .distinct // Remove any duplicates caused by lowercase/uppercase repetition + + ByList(MappedConsent.mStatus, distinctLowerAndUpperCaseStatuses) } Seq( @@ -86,14 +102,60 @@ object MappedConsentProvider extends ConsentProvider { ).flatten } + private def sortConsents(consents: List[MappedConsent], sortByParam: String): List[MappedConsent] = { + // Parse sort_by param like "created_date:desc,status:asc,consumer_id:asc" + val sortFields: List[(String, String)] = sortByParam + .split(",") + .toList + .map(_.trim) + .filter(_.nonEmpty) + .map { fieldSpec => + val parts = fieldSpec.split(":").map(_.trim.toLowerCase) + val fieldName = parts(0) + val sortOrder = parts.lift(1).getOrElse("asc") // default to asc + (fieldName, sortOrder) + } + + // Apply sorting in reverse order, so first field becomes the last sort (because sortBy is stable) + sortFields.reverse.foldLeft(consents) { case (acc, (fieldName, sortOrder)) => + val ascending = sortOrder != "desc" + + fieldName match { + case "created_date" => + if (ascending) + acc.sortBy(_.createdAt.get) + else + acc.sortBy(_.createdAt.get)(Ordering[java.util.Date].reverse) + + case "status" => + if (ascending) + acc.sortBy(_.status)(Ordering[String]) + else + acc.sortBy(_.status)(Ordering[String].reverse) + + case "consumer_id" => + if (ascending) + acc.sortBy(_.consumerId)(Ordering[String]) + else + acc.sortBy(_.consumerId)(Ordering[String].reverse) + + case _ => + // Unknown field → ignore + acc + } + } + } + + override def getConsents(queryParams: List[OBPQueryParam]): List[MappedConsent] = { val optionalParams = getQueryParams(queryParams) + val sortBy: Option[String] = queryParams.collectFirst { case OBPSortBy(value) => value } val consents = MappedConsent.findAll(optionalParams: _*) val bankId: Option[String] = queryParams.collectFirst { case OBPBankId(value) => value } if(bankId.isDefined) { Consent.filterStrictlyByBank(consents, bankId.get) } else { - consents + sortConsents(consents, sortBy.getOrElse("")) } } override def createObpConsent(user: User, challengeAnswer: String, consentRequestId:Option[String], consumer: Option[Consumer]): Box[MappedConsent] = { From 42f1207ccf5f8d334bf651f061603ed2cad5a724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 2 Jul 2025 15:12:27 +0200 Subject: [PATCH 05/33] feature/Improve checkConsent function to always verify signature first --- .../scala/code/api/util/ConsentUtil.scala | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index ec463c972..bc17dba64 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -239,32 +239,36 @@ object Consent extends MdcLoggable { val consentBox = Consents.consentProvider.vend.getConsentByConsentId(consent.jti) logger.debug(s"code.api.util.Consent.checkConsent.getConsentByConsentId: consentBox($consentBox)") val result = consentBox match { - case Full(c) if c.mStatus.toString().toUpperCase == ConsentStatus.ACCEPTED.toString | c.mStatus.toString().toLowerCase() == ConsentStatus.valid.toString => - verifyHmacSignedJwt(consentIdAsJwt, c) match { - case true => - (System.currentTimeMillis / 1000) match { - case currentTimeInSeconds if currentTimeInSeconds < consent.nbf => - Failure(ErrorMessages.ConsentNotBeforeIssue) - case currentTimeInSeconds if currentTimeInSeconds > consent.exp => - Failure(ErrorMessages.ConsentExpiredIssue) - case _ => - logger.debug(s"start code.api.util.Consent.checkConsent.checkConsumerIsActiveAndMatched(consent($consent))") - val result = checkConsumerIsActiveAndMatched(consent, callContext) - logger.debug(s"end code.api.util.Consent.checkConsent.checkConsumerIsActiveAndMatched: result($result)") - result + case Full(c) => + // Always verify signature first + if (!verifyHmacSignedJwt(consentIdAsJwt, c)) { + Failure(ErrorMessages.ConsentVerificationIssue) + } else { + // Then check time constraints + val currentTimeInSeconds = System.currentTimeMillis / 1000 + if (currentTimeInSeconds < consent.nbf) { + Failure(ErrorMessages.ConsentNotBeforeIssue) + } else if (currentTimeInSeconds > consent.exp) { + Failure(ErrorMessages.ConsentExpiredIssue) + } else { + // Then check consent status + if (c.apiStandard == ConstantsBG.berlinGroupVersion1.apiStandard && + c.status.toLowerCase != ConsentStatus.valid.toString) { + Failure(s"${ErrorMessages.ConsentStatusIssue}${ConsentStatus.valid.toString}.") + } else if (c.mStatus.toString.toUpperCase != ConsentStatus.ACCEPTED.toString) { + Failure(s"${ErrorMessages.ConsentStatusIssue}${ConsentStatus.ACCEPTED.toString}.") + } else { + logger.debug(s"start code.api.util.Consent.checkConsent.checkConsumerIsActiveAndMatched(consent($consent))") + val consumerResult = checkConsumerIsActiveAndMatched(consent, callContext) + logger.debug(s"end code.api.util.Consent.checkConsent.checkConsumerIsActiveAndMatched: result($consumerResult)") + consumerResult } - case false => - Failure(ErrorMessages.ConsentVerificationIssue) + } } - case Full(c) if c.apiStandard == ConstantsBG.berlinGroupVersion1.apiStandard && // Berlin Group Consent - c.status.toLowerCase() != ConsentStatus.valid.toString => - Failure(s"${ErrorMessages.ConsentStatusIssue}${ConsentStatus.valid.toString}.") - case Full(c) if c.mStatus.toString().toUpperCase() != ConsentStatus.ACCEPTED.toString => - Failure(s"${ErrorMessages.ConsentStatusIssue}${ConsentStatus.ACCEPTED.toString}.") case _ => Failure(ErrorMessages.ConsentNotFound) } - logger.debug(s"code.api.util.Consent.checkConsent.consentBox.result: result($result)") + logger.debug(s"code.api.util.Consent.checkConsent.result: result($result)") result } From 480fb59eca57a4fb92ad9a62987ce570c0699f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 2 Jul 2025 16:15:01 +0200 Subject: [PATCH 06/33] bugfix/Status ACCEPTED should be checked only for OBP consents --- obp-api/src/main/scala/code/api/util/ConsentUtil.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index bc17dba64..8e2db1de0 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -26,6 +26,7 @@ import code.views.Views import com.nimbusds.jwt.JWTClaimsSet import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model._ +import com.openbankproject.commons.util.ApiStandards import net.liftweb.common._ import net.liftweb.http.provider.HTTPParam import net.liftweb.json.JsonParser.ParseException @@ -255,7 +256,8 @@ object Consent extends MdcLoggable { if (c.apiStandard == ConstantsBG.berlinGroupVersion1.apiStandard && c.status.toLowerCase != ConsentStatus.valid.toString) { Failure(s"${ErrorMessages.ConsentStatusIssue}${ConsentStatus.valid.toString}.") - } else if (c.mStatus.toString.toUpperCase != ConsentStatus.ACCEPTED.toString) { + } else if ((c.apiStandard == ApiStandards.obp.toString || c.apiStandard.isBlank) && + c.mStatus.toString.toUpperCase != ConsentStatus.ACCEPTED.toString) { Failure(s"${ErrorMessages.ConsentStatusIssue}${ConsentStatus.ACCEPTED.toString}.") } else { logger.debug(s"start code.api.util.Consent.checkConsent.checkConsumerIsActiveAndMatched(consent($consent))") From a8515842a50ff5c05735b7baa1e75f5fc3d5b2e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 2 Jul 2025 16:29:04 +0200 Subject: [PATCH 07/33] feature/Enhance Berlin Group error messages --- .../code/api/util/BerlinGroupSigning.scala | 15 ++++---- .../main/scala/code/api/util/ErrorUtil.scala | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/ErrorUtil.scala 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 80976b674..b5b23376a 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala @@ -1,14 +1,15 @@ package code.api.util -import code.api.RequestHeader -import code.api.util.APIUtil.OBPReturnType +import code.api.{APIFailureNewStyle, RequestHeader} +import code.api.util.APIUtil.{OBPReturnType, fullBoxOrException} +import code.api.util.ErrorUtil.apiFailure import code.api.util.newstyle.RegulatedEntityNewStyle.getRegulatedEntitiesNewStyle import code.consumer.Consumers import code.model.Consumer import code.util.Helper.MdcLoggable import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.{RegulatedEntityTrait, User} -import net.liftweb.common.{Box, Failure, Full} +import net.liftweb.common.{Box, Empty, Failure, Full} import net.liftweb.http.provider.HTTPParam import net.liftweb.util.Helpers @@ -181,16 +182,16 @@ object BerlinGroupSigning extends MdcLoggable { (isVerified, isValidated) match { case (true, true) => forwardResult case (true, false) if bypassValidation => forwardResult - case (true, false) => (Failure(ErrorMessages.X509PublicKeyCannotBeValidated), forwardResult._2) - case (false, _) => (Failure(ErrorMessages.X509PublicKeyCannotVerify), forwardResult._2) + case (true, false) => apiFailure(ErrorMessages.X509PublicKeyCannotBeValidated, 401)(forwardResult) + case (false, _) => apiFailure(ErrorMessages.X509PublicKeyCannotVerify, 401)(forwardResult) } } else { // The two DIGEST hashes do NOT match, the integrity of the request body is NOT confirmed. logger.debug(s"Generated digest: $generatedDigest") logger.debug(s"Request header digest: $requestHeaderDigest") - (Failure(ErrorMessages.X509PublicKeyCannotVerify), forwardResult._2) + apiFailure(ErrorMessages.X509PublicKeyCannotVerify, 401)(forwardResult) } case Failure(msg, t, c) => (Failure(msg, t, c), forwardResult._2) // PEM certificate is not valid - case _ => (Failure(ErrorMessages.X509GeneralError), forwardResult._2) // PEM certificate cannot be validated + case _ => apiFailure(ErrorMessages.X509GeneralError, 401)(forwardResult) // PEM certificate cannot be validated } } } diff --git a/obp-api/src/main/scala/code/api/util/ErrorUtil.scala b/obp-api/src/main/scala/code/api/util/ErrorUtil.scala new file mode 100644 index 000000000..36643a17d --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/ErrorUtil.scala @@ -0,0 +1,34 @@ +package code.api.util + +import code.api.APIFailureNewStyle +import code.api.util.APIUtil.fullBoxOrException +import com.openbankproject.commons.model.User +import net.liftweb.common.{Box, Empty, Failure} + + +object ErrorUtil { + def apiFailure(errorMessage: String, httpCode: Int)(forwardResult: (Box[User], Option[CallContext])): (Box[User], Option[CallContext]) = { + val (_, second) = forwardResult + val apiFailure = APIFailureNewStyle( + failMsg = errorMessage, + failCode = httpCode, + ccl = second.map(_.toLight) + ) + val failureBox = Empty ~> apiFailure + ( + fullBoxOrException(failureBox), + second + ) + } + + def apiFailureToBox(errorMessage: String, httpCode: Int)(cc: Option[CallContext]): Box[Nothing] = { + val apiFailure = APIFailureNewStyle( + failMsg = errorMessage, + failCode = httpCode, + ccl = cc.map(_.toLight) + ) + val failureBox = Empty ~> apiFailure + fullBoxOrException(failureBox) + } + +} From 927ba2c3af6274ab811bd4ee3eff1a0306a16fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 3 Jul 2025 17:54:14 +0200 Subject: [PATCH 08/33] feature/Prevent misusing of Consent-ID at Request Header --- .../src/main/scala/code/api/util/APIUtil.scala | 3 +++ .../scala/code/api/util/BerlinGroupCheck.scala | 16 ++++++++++++++++ .../scala/code/api/util/BerlinGroupError.scala | 1 + .../main/scala/code/api/util/ErrorMessages.scala | 1 + 4 files changed, 21 insertions(+) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 2d3157abb..a20461bd6 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3028,6 +3028,9 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ Future { (fullBoxOrException(Empty ~> APIFailureNewStyle(message, 400, Some(cc.toLight))), Some(cc)) } } else if (authHeaders.size > 1) { // Check Authorization Headers ambiguity Future { (Failure(ErrorMessages.AuthorizationHeaderAmbiguity + s"${authHeaders}"), Some(cc)) } + } else if (BerlinGroupCheck.doNotUseConsentIdAtHeader(url, reqHeaders)) { + val message = ErrorMessages.InvalidConsentIdUsage + Future { (fullBoxOrException(Empty ~> APIFailureNewStyle(message, 400, Some(cc.toLight))), Some(cc)) } } else if (APIUtil.`hasConsent-ID`(reqHeaders)) { // Berlin Group's Consent Consent.applyBerlinGroupRules(APIUtil.`getConsent-ID`(reqHeaders), cc.copy(consumer = consumerByCertificate)) } else if (APIUtil.hasConsentJWT(reqHeaders)) { // Open Bank Project's Consent 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 2679dc0a0..c7c28e430 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -29,6 +29,22 @@ object BerlinGroupCheck extends MdcLoggable { .map(_.trim.toLowerCase) .toList.filterNot(_.isEmpty) + def doNotUseConsentIdAtHeader(path: String, reqHeaders: List[HTTPParam]): Boolean = { + val headerMap: Map[String, HTTPParam] = reqHeaders.map(h => h.name.toLowerCase -> h).toMap + val hasConsentIdId = headerMap.get(RequestHeader.`Consent-ID`.toLowerCase).flatMap(_.values.headOption).isDefined + + val parts = path.stripPrefix("/").stripSuffix("/").split("/").toList + val doesNotRequireConsentId = parts.reverse match { + case "consents" :: restOfThePath => true + case consentId :: "consents" :: restOfThePath => true + case "status" :: consentId :: "consents" :: restOfThePath => true + case "authorisations" :: consentId :: "consents" :: restOfThePath => true + case authorisationId :: "authorisations" :: consentId :: "consents" :: restOfThePath => true + case _ => false + } + doesNotRequireConsentId && hasConsentIdId && path.contains(ConstantsBG.berlinGroupVersion1.urlPrefix) + } + private def validateHeaders( verb: String, url: String, diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala index c44914a4e..90959f9d0 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala @@ -95,6 +95,7 @@ object BerlinGroupError { case "400" if message.contains("OBP-20253") => "FORMAT_ERROR" case "400" if message.contains("OBP-20254") => "FORMAT_ERROR" case "400" if message.contains("OBP-20255") => "FORMAT_ERROR" + case "400" if message.contains("OBP-20256") => "FORMAT_ERROR" case "400" if message.contains("OBP-20251") => "FORMAT_ERROR" case "400" if message.contains("OBP-20088") => "FORMAT_ERROR" case "400" if message.contains("OBP-20089") => "FORMAT_ERROR" 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 fed389094..2f3137c38 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -277,6 +277,7 @@ object ErrorMessages { val InvalidUuidValue = "OBP-20253: Invalid format. Must be a UUID." val InvalidSignatureHeader = "OBP-20254: Invalid Signature header. " val InvalidRequestIdValueAlreadyUsed = "OBP-20255: Request Id value already used. " + val InvalidConsentIdUsage = "OBP-20256: Consent-Id must not be used for this API. " // X.509 val X509GeneralError = "OBP-20300: PEM Encoded Certificate issue." From 5240d47ec7a6ec28e6ea05df0c18ca4345cd57fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 4 Jul 2025 12:14:43 +0200 Subject: [PATCH 09/33] feature/Improve error message at function createBerlinGroupConsentJWT --- .../code/api/util/BerlinGroupError.scala | 1 + .../scala/code/api/util/ConsentUtil.scala | 156 +++++++++--------- .../main/scala/code/api/util/ErrorUtil.scala | 4 +- 3 files changed, 84 insertions(+), 77 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala index 90959f9d0..509042731 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala @@ -80,6 +80,7 @@ object BerlinGroupError { case "400" if message.contains("OBP-35001") => "CONSENT_UNKNOWN" case "403" if message.contains("OBP-35001") => "CONSENT_UNKNOWN" + case "400" if message.contains("OBP-50200") => "RESOURCE_UNKNOWN" case "404" if message.contains("OBP-30076") => "RESOURCE_UNKNOWN" case "404" if message.contains("OBP-40001") => "RESOURCE_UNKNOWN" diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index 8e2db1de0..b84d656ab 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -739,89 +739,95 @@ object Consent extends MdcLoggable { callContext: Option[CallContext]): Future[Box[String]] = { val currentTimeInSeconds = System.currentTimeMillis / 1000 - val validUntilTimeInSeconds = validUntil match { - case Some(date) => date.getTime() / 1000 - case _ => currentTimeInSeconds - } - // Write Consent's Auth Context to the DB - user map { u => + val validUntilTimeInSeconds = validUntil.map(_.getTime / 1000).getOrElse(currentTimeInSeconds) + + // Write Consent's Auth Context to DB + user.foreach { u => val authContexts = UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(u.userId) .map(_.map(i => BasicUserAuthContext(i.key, i.value))) ConsentAuthContextProvider.consentAuthContextProvider.vend.createOrUpdateConsentAuthContexts(consentId, authContexts.getOrElse(Nil)) } - - // 1. Add access + + // Helper to get ConsentView or fail box + def getConsentView(ibanOpt: Option[String], viewId: String): Future[Box[ConsentView]] = { + val iban = ibanOpt.getOrElse("") + Connector.connector.vend.getBankAccountByIban(iban, callContext).map { bankAccount => + logger.debug(s"createBerlinGroupConsentJWT.bankAccount: $bankAccount") + val error = s"${InvalidConnectorResponse} IBAN: $iban ${handleBox(bankAccount._1)}" + bankAccount._1 match { + case Full(acc) => + Full(ConsentView( + bank_id = acc.bankId.value, + account_id = acc.accountId.value, + view_id = viewId, + None + )) + case _ => + ErrorUtil.apiFailureToBox(error, 400)(callContext) + } + } + } + + // Prepare lists of future boxes val allAccesses = consent.access.accounts.getOrElse(Nil) ::: - consent.access.balances.getOrElse(Nil) ::: // Balances access implies and Account access as well - consent.access.transactions.getOrElse(Nil) // Transactions access implies and Account access as well - val accounts: List[Future[ConsentView]] = allAccesses.distinct map { account => - Connector.connector.vend.getBankAccountByIban(account.iban.getOrElse(""), callContext) map { bankAccount => - logger.debug(s"createBerlinGroupConsentJWT.accounts.bankAccount: $bankAccount") - val error = s"${InvalidConnectorResponse} IBAN: ${account.iban.getOrElse("")} ${handleBox(bankAccount._1)}" - ConsentView( - bank_id = bankAccount._1.map(_.bankId.value).getOrElse(""), - account_id = bankAccount._1.map(_.accountId.value).openOrThrowException(error), - view_id = Constant.SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID, - None - ) + consent.access.balances.getOrElse(Nil) ::: + consent.access.transactions.getOrElse(Nil) + + val accounts: List[Future[Box[ConsentView]]] = allAccesses.distinct.map { account => + getConsentView(account.iban, Constant.SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID) + } + + val balances: List[Future[Box[ConsentView]]] = consent.access.balances.getOrElse(Nil).map { account => + getConsentView(account.iban, Constant.SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID) + } + val transactions: List[Future[Box[ConsentView]]] = consent.access.transactions.getOrElse(Nil).map { account => + getConsentView(account.iban, Constant.SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID) + } + + // Collect optional headers + val headers = callContext.map(_.requestHeaders).getOrElse(Nil) + val tppRedirectUri = headers.find(_.name == RequestHeader.`TPP-Redirect-URI`) + val tppNokRedirectUri = headers.find(_.name == RequestHeader.`TPP-Nok-Redirect-URI`) + val xRequestId = headers.find(_.name == RequestHeader.`X-Request-ID`) + val psuDeviceId = headers.find(_.name == RequestHeader.`PSU-Device-ID`) + val psuIpAddress = headers.find(_.name == RequestHeader.`PSU-IP-Address`) + val psuGeoLocation = headers.find(_.name == RequestHeader.`PSU-Geo-Location`) + + def sequenceBoxes[A](boxes: List[Box[A]]): Box[List[A]] = { + boxes.foldRight(Full(Nil): Box[List[A]]) { (box, acc) => + for { + x <- box + xs <- acc + } yield x :: xs } } - val balances: List[Future[ConsentView]] = consent.access.balances.getOrElse(Nil) map { account => - Connector.connector.vend.getBankAccountByIban(account.iban.getOrElse(""), callContext) map { bankAccount => - logger.debug(s"createBerlinGroupConsentJWT.balances.bankAccount: $bankAccount") - val error = s"${InvalidConnectorResponse} IBAN: ${account.iban.getOrElse("")} ${handleBox(bankAccount._1)}" - ConsentView( - bank_id = bankAccount._1.map(_.bankId.value).getOrElse(""), - account_id = bankAccount._1.map(_.accountId.value).openOrThrowException(error), - view_id = Constant.SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID, - None - ) - } - } - val transactions: List[Future[ConsentView]] = consent.access.transactions.getOrElse(Nil) map { account => - Connector.connector.vend.getBankAccountByIban(account.iban.getOrElse(""), callContext) map { bankAccount => - logger.debug(s"createBerlinGroupConsentJWT.transactions.bankAccount: $bankAccount") - val error = s"${InvalidConnectorResponse} IBAN: ${account.iban.getOrElse("")} ${handleBox(bankAccount._1)}" - ConsentView( - bank_id = bankAccount._1.map(_.bankId.value).getOrElse(""), - account_id = bankAccount._1.map(_.accountId.value).openOrThrowException(error), - view_id = Constant.SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID, - None - ) - } - } - val tppRedirectUri: Option[HTTPParam] = callContext.map(_.requestHeaders).getOrElse(Nil).find(_.name == RequestHeader.`TPP-Redirect-URI`) - val tppNokRedirectUri: Option[HTTPParam] = callContext.map(_.requestHeaders).getOrElse(Nil).find(_.name == RequestHeader.`TPP-Nok-Redirect-URI`) - val xRequestId: Option[HTTPParam] = callContext.map(_.requestHeaders).getOrElse(Nil).find(_.name == RequestHeader.`X-Request-ID`) - val psuDeviceId: Option[HTTPParam] = callContext.map(_.requestHeaders).getOrElse(Nil).find(_.name == RequestHeader.`PSU-Device-ID`) - val psuIpAddress: Option[HTTPParam] = callContext.map(_.requestHeaders).getOrElse(Nil).find(_.name == RequestHeader.`PSU-IP-Address`) - val psuGeoLocation: Option[HTTPParam] = callContext.map(_.requestHeaders).getOrElse(Nil).find(_.name == RequestHeader.`PSU-Geo-Location`) - Future.sequence(accounts ::: balances ::: transactions) map { views => + + // Combine and build final JWT + Future.sequence(accounts ::: balances ::: transactions).map { listOfBoxes => + sequenceBoxes(listOfBoxes).map { views => val json = ConsentJWT( - createdByUserId = user.map(_.userId).getOrElse(""), - sub = APIUtil.generateUUID(), - iss = Constant.HostName, - aud = consumerId.getOrElse(""), - jti = consentId, - iat = currentTimeInSeconds, - nbf = currentTimeInSeconds, - exp = validUntilTimeInSeconds, - request_headers = tppRedirectUri.toList ::: - tppNokRedirectUri.toList ::: - xRequestId.toList ::: - psuDeviceId.toList ::: - psuIpAddress.toList ::: - psuGeoLocation.toList, - name = None, - email = None, - entitlements = Nil, - views = views, - access = Some(consent.access) - ) - implicit val formats = CustomJsonFormats.formats - val jwtPayloadAsJson = compactRender(Extraction.decompose(json)) - val jwtClaims: JWTClaimsSet = JWTClaimsSet.parse(jwtPayloadAsJson) - Full(CertificateUtil.jwtWithHmacProtection(jwtClaims, secret)) + createdByUserId = user.map(_.userId).getOrElse(""), + sub = APIUtil.generateUUID(), + iss = Constant.HostName, + aud = consumerId.getOrElse(""), + jti = consentId, + iat = currentTimeInSeconds, + nbf = currentTimeInSeconds, + exp = validUntilTimeInSeconds, + request_headers = List( + tppRedirectUri, tppNokRedirectUri, xRequestId, psuDeviceId, psuIpAddress, psuGeoLocation + ).flatten, + name = None, + email = None, + entitlements = Nil, + views = views, + access = Some(consent.access) + ) + implicit val formats = CustomJsonFormats.formats + val jwtPayloadAsJson = compactRender(Extraction.decompose(json)) + val jwtClaims: JWTClaimsSet = JWTClaimsSet.parse(jwtPayloadAsJson) + CertificateUtil.jwtWithHmacProtection(jwtClaims, secret) + } } } def updateAccountAccessOfBerlinGroupConsentJWT(access: ConsentAccessJson, diff --git a/obp-api/src/main/scala/code/api/util/ErrorUtil.scala b/obp-api/src/main/scala/code/api/util/ErrorUtil.scala index 36643a17d..980d08d83 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorUtil.scala @@ -21,13 +21,13 @@ object ErrorUtil { ) } - def apiFailureToBox(errorMessage: String, httpCode: Int)(cc: Option[CallContext]): Box[Nothing] = { + def apiFailureToBox[T](errorMessage: String, httpCode: Int)(cc: Option[CallContext]): Box[T] = { val apiFailure = APIFailureNewStyle( failMsg = errorMessage, failCode = httpCode, ccl = cc.map(_.toLight) ) - val failureBox = Empty ~> apiFailure + val failureBox: Box[T] = Empty ~> apiFailure fullBoxOrException(failureBox) } From 80031e24ffd3cc7fae95b95b2f9a80467208026f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 4 Jul 2025 14:15:04 +0200 Subject: [PATCH 10/33] feature/Improve error message for CONSENT_EXPIRED error --- obp-api/src/main/scala/code/api/util/ConsentUtil.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index b84d656ab..69bed910b 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -250,7 +250,7 @@ object Consent extends MdcLoggable { if (currentTimeInSeconds < consent.nbf) { Failure(ErrorMessages.ConsentNotBeforeIssue) } else if (currentTimeInSeconds > consent.exp) { - Failure(ErrorMessages.ConsentExpiredIssue) + ErrorUtil.apiFailureToBox(ErrorMessages.ConsentExpiredIssue, 401)(Some(callContext)) } else { // Then check consent status if (c.apiStandard == ConstantsBG.berlinGroupVersion1.apiStandard && From ce84dea6038137a324f42558e9075eb7a03529dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 4 Jul 2025 14:52:00 +0200 Subject: [PATCH 11/33] feature/Improve error message for SERVICE_INVALID error --- obp-api/src/main/scala/bootstrap/liftweb/Boot.scala | 9 ++++++++- obp-api/src/main/scala/code/api/util/ApiSession.scala | 6 +++--- .../src/main/scala/code/api/util/BerlinGroupError.scala | 2 ++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 5690b0f50..4c70ea028 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -39,6 +39,7 @@ import code.api.ResourceDocs1_4_0.ResourceDocs300.{ResourceDocs310, ResourceDocs import code.api.ResourceDocs1_4_0._ import code.api._ import code.api.attributedefinition.AttributeDefinition +import code.api.berlin.group.ConstantsBG import code.api.cache.Redis import code.api.util.APIUtil.{enableVersionIfAllowed, errorJsonResponse, getPropsValue} import code.api.util.ApiRole.CanCreateEntitlementAtAnyBank @@ -722,8 +723,14 @@ class Boot extends MdcLoggable { } LiftRules.uriNotFound.prepend{ + case (r, aaa) if r.uri.contains(ConstantsBG.berlinGroupVersion1.urlPrefix) => NotFoundAsResponse(errorJsonResponse( + s"${ErrorMessages.InvalidUri}Current Url is (${r.uri.toString}), Current Content-Type Header is (${r.headers.find(_._1.equals("Content-Type")).map(_._2).getOrElse("")})", + 405, + Some(CallContextLight(url = r.uri)) + ) + ) case (r, _) => NotFoundAsResponse(errorJsonResponse( - s"${ErrorMessages.InvalidUri}Current Url is (${r.uri.toString}), Current Content-Type Header is (${r.headers.find(_._1.equals("Content-Type")).map(_._2).getOrElse("")})", + s"${ErrorMessages.InvalidUri}Current Url is (${r.uri.toString}), Current Content-Type Header is (${r.headers.find(_._1.equals("Content-Type")).map(_._2).getOrElse("")})", 404) ) } diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index e4aa24e91..abe656c66 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -203,9 +203,9 @@ case class CallContextLight(gatewayLoginRequestPayload: Option[PayloadOfJwtJSON] httpBody: Option[String] = None, authReqHeaderField: Option[String] = None, requestHeaders: List[HTTPParam] = Nil, - partialFunctionName: String, - directLoginToken: String, - oAuthToken: String, + partialFunctionName: String = "", + directLoginToken: String = "", + oAuthToken: String = "", xRateLimitLimit : Long = -1, xRateLimitRemaining : Long = -1, xRateLimitReset : Long = -1, diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala index 509042731..df6875c6f 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala @@ -106,6 +106,8 @@ object BerlinGroupError { case "400" if message.contains("OBP-50221") => "PAYMENT_FAILED" + case "405" if message.contains("OBP-10404") => "SERVICE_INVALID" + case "429" if message.contains("OBP-10018") => "ACCESS_EXCEEDED" From abff73aef4662f8aa9a2492198a2d564c8e1027c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 4 Jul 2025 14:52:30 +0200 Subject: [PATCH 12/33] =?UTF-8?q?Revert=20"feature/OBP=20API=20=E2=80=93?= =?UTF-8?q?=20Docker=20&=20Docker=20Compose=20Setup"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 05bf4019a2154df3d598c31809c80be57b4575bd. --- docker/Dockerfile | 18 ------ docker/README.md | 96 ------------------------------ docker/docker-compose.override.yml | 7 --- docker/docker-compose.yml | 14 ----- docker/entrypoint.sh | 9 --- 5 files changed, 144 deletions(-) delete mode 100644 docker/Dockerfile delete mode 100644 docker/README.md delete mode 100644 docker/docker-compose.override.yml delete mode 100644 docker/docker-compose.yml delete mode 100755 docker/entrypoint.sh diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 53a999d1d..000000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM maven:3.9.6-eclipse-temurin-17 - -WORKDIR /app - -# Copy all project files into container -COPY . . - -EXPOSE 8080 - -# Build the project, skip tests to speed up -RUN mvn install -pl .,obp-commons -am -DskipTests - -# Copy entrypoint script that runs mvn with needed JVM flags -COPY docker/entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh - -# Use script as entrypoint -CMD ["/app/entrypoint.sh"] diff --git a/docker/README.md b/docker/README.md deleted file mode 100644 index 1c46f09e0..000000000 --- a/docker/README.md +++ /dev/null @@ -1,96 +0,0 @@ -## OBP API – Docker & Docker Compose Setup - -This project uses Docker and Docker Compose to run the **OBP API** service with Maven and Jetty. - -- Java 17 with reflection workaround -- Connects to your local Postgres using `host.docker.internal` -- Supports separate dev & prod setups - ---- - -## How to use - -> **Make sure you have Docker and Docker Compose installed.** - -### Set up the database connection - -Edit your `default.properties` (or similar config file): - -```properties -db.url=jdbc:postgresql://host.docker.internal:5432/YOUR_DB_NAME?user=YOUR_DB_USER&password=YOUR_DB_PASSWORD -```` - -> Use `host.docker.internal` so the container can reach your local database. - ---- - -### Build & run (production mode) - -Build the Docker image and run the container: - -```bash -docker-compose up --build -``` - -The service will be available at [http://localhost:8080](http://localhost:8080). - ---- - -## Development tips - -For live code updates without rebuilding: - -* Use the provided `docker-compose.override.yml` which mounts only: - - ```yaml - volumes: - - ../obp-api:/app/obp-api - - ../obp-commons:/app/obp-commons - ``` -* This keeps other built files (like `entrypoint.sh`) intact. -* Avoid mounting the full `../:/app` because it overwrites the built image. - ---- - -## Useful commands - -Rebuild the image and restart: - -```bash -docker-compose up --build -``` - -Stop the container: - -```bash -docker-compose down -``` - ---- - -## Before first run - -Make sure your entrypoint script is executable: - -```bash -chmod +x docker/entrypoint.sh -``` - ---- - -## Notes - -* The container uses `MAVEN_OPTS` to pass JVM `--add-opens` flags needed by Lift. -* In production, avoid volume mounts for better performance and consistency. - ---- - -That’s it — now you can run: - -```bash -docker-compose up --build -``` - -and start coding! - -``` \ No newline at end of file diff --git a/docker/docker-compose.override.yml b/docker/docker-compose.override.yml deleted file mode 100644 index 80e973a2c..000000000 --- a/docker/docker-compose.override.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: "3.8" - -services: - obp-api: - volumes: - - ../obp-api:/app/obp-api - - ../obp-commons:/app/obp-commons diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml deleted file mode 100644 index ca4eda42a..000000000 --- a/docker/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: "3.8" - -services: - obp-api: - build: - context: .. - dockerfile: docker/Dockerfile - ports: - - "8080:8080" - extra_hosts: - # Connect to local Postgres on the host - # In your config file: - # db.url=jdbc:postgresql://host.docker.internal:5432/YOUR_DB?user=YOUR_DB_USER&password=YOUR_DB_PASSWORD - - "host.docker.internal:host-gateway" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100755 index b35048478..000000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash -set -e - -export MAVEN_OPTS="-Xss128m \ - --add-opens=java.base/java.util.jar=ALL-UNNAMED \ - --add-opens=java.base/java.lang=ALL-UNNAMED \ - --add-opens=java.base/java.lang.reflect=ALL-UNNAMED" - -exec mvn jetty:run -pl obp-api From 0af54e38bbb8ef4675d5be593b0e98c4911cef3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 7 Jul 2025 13:07:48 +0200 Subject: [PATCH 13/33] feature/Improve handling of error message --- .../src/main/scala/bootstrap/liftweb/Boot.scala | 2 +- obp-api/src/main/scala/code/api/util/APIUtil.scala | 2 +- .../src/main/scala/code/api/util/ConsentUtil.scala | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 4c70ea028..3edfbb7a1 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -723,7 +723,7 @@ class Boot extends MdcLoggable { } LiftRules.uriNotFound.prepend{ - case (r, aaa) if r.uri.contains(ConstantsBG.berlinGroupVersion1.urlPrefix) => NotFoundAsResponse(errorJsonResponse( + case (r, _) if r.uri.contains(ConstantsBG.berlinGroupVersion1.urlPrefix) => NotFoundAsResponse(errorJsonResponse( s"${ErrorMessages.InvalidUri}Current Url is (${r.uri.toString}), Current Content-Type Header is (${r.headers.find(_._1.equals("Content-Type")).map(_._2).getOrElse("")})", 405, Some(CallContextLight(url = r.uri)) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index a20461bd6..f95c3c254 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3400,7 +3400,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ val apiFailure = af.copy(failMsg = failuresMsg).copy(ccl = callContext) throw new Exception(JsonAST.compactRender(Extraction.decompose(apiFailure))) case ParamFailure(_, _, _, failure : APIFailure) => - val callContext = CallContextLight(partialFunctionName = "", directLoginToken= "", oAuthToken= "") + val callContext = CallContextLight() val apiFailure = APIFailureNewStyle(failMsg = failure.msg, failCode = failure.responseCode, ccl = Some(callContext)) throw new Exception(JsonAST.compactRender(Extraction.decompose(apiFailure))) case ParamFailure(msg,_,_,_) => diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index 69bed910b..6ac6c0046 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -594,18 +594,18 @@ object Consent extends MdcLoggable { Future(Failure(ErrorMessages.ConsentCheckExpiredIssue), Some(updatedCallContext)) } } catch { // Possible exceptions - case e: ParseException => { + case e: ParseException => logger.debug(s"code.api.util.JwtUtil.getSignedPayloadAsJson.ParseException: $e") Future(Failure("ParseException: " + e.getMessage), Some(updatedCallContext)) - } - case e: MappingException => { + case e: MappingException => logger.debug(s"code.api.util.JwtUtil.getSignedPayloadAsJson.MappingException: $e") Future(Failure("MappingException: " + e.getMessage), Some(updatedCallContext)) - } - case e: Throwable => { + case e: Throwable => logger.debug(s"code.api.util.JwtUtil.getSignedPayloadAsJson.Throwable: $e") - Future(Failure("parsing failed: " + e.getMessage), Some(updatedCallContext)) - } + val message = net.liftweb.json.parse(e.getMessage) + .extractOpt[APIFailureNewStyle].map(_.failMsg) // Extract message from APIFailureNewStyle + .getOrElse(e.getMessage) // or fail to original one + Future(Failure(message), Some(updatedCallContext)) } case failure@Failure(_, _, _) => Future(failure, Some(updatedCallContext)) From ff34164424506bced5dab7980cb5aa6be07d277b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 7 Jul 2025 13:09:10 +0200 Subject: [PATCH 14/33] docfix/Tweak error message InvalidConsentIdUsage --- obp-api/src/main/scala/code/api/util/ErrorMessages.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2f3137c38..f4601288a 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -277,7 +277,7 @@ object ErrorMessages { val InvalidUuidValue = "OBP-20253: Invalid format. Must be a UUID." val InvalidSignatureHeader = "OBP-20254: Invalid Signature header. " val InvalidRequestIdValueAlreadyUsed = "OBP-20255: Request Id value already used. " - val InvalidConsentIdUsage = "OBP-20256: Consent-Id must not be used for this API. " + val InvalidConsentIdUsage = "OBP-20256: Consent-Id must not be used for this API Endpoint. " // X.509 val X509GeneralError = "OBP-20300: PEM Encoded Certificate issue." From ece3ce865e282720f2c2bbf6e4bbf99ee58625fe Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 7 Jul 2025 16:56:33 +0200 Subject: [PATCH 15/33] refactor/improve error messages for user creation process --- obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala b/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala index 9c3247e75..15dbbda2b 100644 --- a/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala +++ b/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala @@ -1321,7 +1321,7 @@ trait APIMethods200 { _ <- Helper.booleanToFuture(ErrorMessages.InvalidStrongPasswordFormat, 400, cc.callContext) { fullPasswordValidation(postedData.password) } - _ <- Helper.booleanToFuture("User with the same username already exists.", 409, cc.callContext) { + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat User with the same username already exists.", 409, cc.callContext) { AuthUser.find(By(AuthUser.username, postedData.username)).isEmpty } userCreated <- Future { @@ -1333,13 +1333,13 @@ trait APIMethods200 { .password(postedData.password) .validated(APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", defaultValue = false)) } - _ <- Helper.booleanToFuture(userCreated.validate.map(_.msg).mkString(";"), 400, cc.callContext) { + _ <- Helper.booleanToFuture(ErrorMessages.InvalidJsonFormat+userCreated.validate.map(_.msg).mkString(";"), 400, cc.callContext) { userCreated.validate.size == 0 } savedUser <- NewStyle.function.tryons(ErrorMessages.InvalidJsonFormat, 400, cc.callContext) { userCreated.saveMe() } - _ <- Helper.booleanToFuture("Error occurred during user creation.", 400, cc.callContext) { + _ <- Helper.booleanToFuture(s"$UnknownError Error occurred during user creation.", 400, cc.callContext) { userCreated.saved_? } } yield { From f0f258b1ed0cef64e688db4e310263320abc3d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 8 Jul 2025 10:38:54 +0200 Subject: [PATCH 16/33] refactor/Add function extractFailureMessage --- .../src/main/scala/code/api/util/ConsentUtil.scala | 5 +---- obp-api/src/main/scala/code/api/util/ErrorUtil.scala | 12 ++++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index 6ac6c0046..cfd10ed09 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -602,10 +602,7 @@ object Consent extends MdcLoggable { Future(Failure("MappingException: " + e.getMessage), Some(updatedCallContext)) case e: Throwable => logger.debug(s"code.api.util.JwtUtil.getSignedPayloadAsJson.Throwable: $e") - val message = net.liftweb.json.parse(e.getMessage) - .extractOpt[APIFailureNewStyle].map(_.failMsg) // Extract message from APIFailureNewStyle - .getOrElse(e.getMessage) // or fail to original one - Future(Failure(message), Some(updatedCallContext)) + Future(Failure(ErrorUtil.extractFailureMessage(e)), Some(updatedCallContext)) } case failure@Failure(_, _, _) => Future(failure, Some(updatedCallContext)) diff --git a/obp-api/src/main/scala/code/api/util/ErrorUtil.scala b/obp-api/src/main/scala/code/api/util/ErrorUtil.scala index 980d08d83..763f1e904 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorUtil.scala @@ -4,6 +4,7 @@ import code.api.APIFailureNewStyle import code.api.util.APIUtil.fullBoxOrException import com.openbankproject.commons.model.User import net.liftweb.common.{Box, Empty, Failure} +import net.liftweb.json._ object ErrorUtil { @@ -31,4 +32,15 @@ object ErrorUtil { fullBoxOrException(failureBox) } + + + implicit val formats: Formats = DefaultFormats + def extractFailureMessage(e: Throwable): String = { + parse(e.getMessage) + .extractOpt[APIFailureNewStyle] // Extract message from APIFailureNewStyle + .map(_.failMsg) // or prpovide a original one + .getOrElse(e.getMessage) + } + + } From 0e3ee8d4ae71106cb92fb345a53562a9130e0727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 8 Jul 2025 11:19:01 +0200 Subject: [PATCH 17/33] refactor/Add function extractFailureMessage 2 --- obp-api/src/main/scala/code/api/util/ErrorUtil.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/ErrorUtil.scala b/obp-api/src/main/scala/code/api/util/ErrorUtil.scala index 763f1e904..a077a41f1 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorUtil.scala @@ -38,7 +38,7 @@ object ErrorUtil { def extractFailureMessage(e: Throwable): String = { parse(e.getMessage) .extractOpt[APIFailureNewStyle] // Extract message from APIFailureNewStyle - .map(_.failMsg) // or prpovide a original one + .map(_.failMsg) // or provide a original one .getOrElse(e.getMessage) } From 667b14a0b7b2ff18cdbc53a74d80078268ecba42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 8 Jul 2025 12:30:11 +0200 Subject: [PATCH 18/33] refactor/Rename doNotUseConsentIdAtHeader to hasUnwantedConsentIdHeaderForBGEndpoint --- obp-api/src/main/scala/code/api/util/APIUtil.scala | 2 +- obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index f95c3c254..379e587e0 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3028,7 +3028,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ Future { (fullBoxOrException(Empty ~> APIFailureNewStyle(message, 400, Some(cc.toLight))), Some(cc)) } } else if (authHeaders.size > 1) { // Check Authorization Headers ambiguity Future { (Failure(ErrorMessages.AuthorizationHeaderAmbiguity + s"${authHeaders}"), Some(cc)) } - } else if (BerlinGroupCheck.doNotUseConsentIdAtHeader(url, reqHeaders)) { + } else if (BerlinGroupCheck.hasUnwantedConsentIdHeaderForBGEndpoint(url, reqHeaders)) { val message = ErrorMessages.InvalidConsentIdUsage Future { (fullBoxOrException(Empty ~> APIFailureNewStyle(message, 400, Some(cc.toLight))), Some(cc)) } } else if (APIUtil.`hasConsent-ID`(reqHeaders)) { // Berlin Group's Consent 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 c7c28e430..0f7574f90 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -29,7 +29,7 @@ object BerlinGroupCheck extends MdcLoggable { .map(_.trim.toLowerCase) .toList.filterNot(_.isEmpty) - def doNotUseConsentIdAtHeader(path: String, reqHeaders: List[HTTPParam]): Boolean = { + def hasUnwantedConsentIdHeaderForBGEndpoint(path: String, reqHeaders: List[HTTPParam]): Boolean = { val headerMap: Map[String, HTTPParam] = reqHeaders.map(h => h.name.toLowerCase -> h).toMap val hasConsentIdId = headerMap.get(RequestHeader.`Consent-ID`.toLowerCase).flatMap(_.values.headOption).isDefined From ac17aa5de7cb7c8317afc83736f38c0422c986cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 8 Jul 2025 14:41:00 +0200 Subject: [PATCH 19/33] feature/keyId.CN is mandatory and SN is checked in dec and hex systems --- .../code/api/util/BerlinGroupCheck.scala | 29 ++++-- .../BerlinGroupSignatureHeaderParser.scala | 95 +++++++++++++++---- 2 files changed, 98 insertions(+), 26 deletions(-) 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 0f7574f90..0e0962a39 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -117,22 +117,32 @@ object BerlinGroupCheck extends MdcLoggable { maybeSignature.flatMap { header => BerlinGroupSignatureHeaderParser.parseSignatureHeader(header) match { case Right(parsed) => - // Log parsed values logger.debug(s"Parsed Signature Header:") logger.debug(s" SN: ${parsed.keyId.sn}") logger.debug(s" CA: ${parsed.keyId.ca}") + logger.debug(s" CN: ${parsed.keyId.cn}") logger.debug(s" O: ${parsed.keyId.o}") logger.debug(s" Headers: ${parsed.headers.mkString(", ")}") logger.debug(s" Signature: ${parsed.signature}") + val certificate = getCertificateFromTppSignatureCertificate(reqHeaders) - val serialNumber = certificate.getSerialNumber.toString - if(parsed.keyId.sn != serialNumber) { - logger.debug(s"Serial number from certificate: $serialNumber") - logger.debug(s"keyId.SN:${parsed.keyId.sn}") + val certSerialNumber = certificate.getSerialNumber + + logger.debug(s"Certificate serial number (decimal): ${certSerialNumber.toString}") + logger.debug(s"Certificate serial number (hex): ${certSerialNumber.toString(16).toUpperCase}") + + val snMatches = BerlinGroupSignatureHeaderParser.doesSerialNumberMatch(parsed.keyId.sn, certSerialNumber) + + if (!snMatches) { + logger.debug(s"Serial number mismatch. Parsed SN: ${parsed.keyId.sn}, Certificate decimal: ${certSerialNumber.toString}, Certificate hex: ${certSerialNumber.toString(16).toUpperCase}") Some( ( fullBoxOrException( - Empty ~> APIFailureNewStyle(s"${ErrorMessages.InvalidSignatureHeader}keyId.SN does not match the serial number from certificate", 400, forwardResult._2.map(_.toLight)) + Empty ~> APIFailureNewStyle( + s"${ErrorMessages.InvalidSignatureHeader} keyId.SN does not match the serial number from certificate", + 400, + forwardResult._2.map(_.toLight) + ) ), forwardResult._2 ) @@ -140,11 +150,16 @@ object BerlinGroupCheck extends MdcLoggable { } else { None // All good } + case Left(error) => Some( ( fullBoxOrException( - Empty ~> APIFailureNewStyle(s"${ErrorMessages.InvalidSignatureHeader}$error", 400, forwardResult._2.map(_.toLight)) + Empty ~> APIFailureNewStyle( + s"${ErrorMessages.InvalidSignatureHeader} $error", + 400, + forwardResult._2.map(_.toLight) + ) ), forwardResult._2 ) diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupSignatureHeaderParser.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupSignatureHeaderParser.scala index b706df333..bba9fd298 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupSignatureHeaderParser.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupSignatureHeaderParser.scala @@ -1,8 +1,10 @@ package code.api.util -object BerlinGroupSignatureHeaderParser { +import code.util.Helper.MdcLoggable - case class ParsedKeyId(sn: String, ca: String, o: String) +object BerlinGroupSignatureHeaderParser extends MdcLoggable { + + case class ParsedKeyId(sn: String, ca: String, cn: String, o: String) case class ParsedSignature(keyId: ParsedKeyId, headers: List[String], signature: String) @@ -18,13 +20,32 @@ object BerlinGroupSignatureHeaderParser { } }.toMap - val caValue = kvMap.get("CA").map(_.stripPrefix("CN=")) + // mandatory fields + val snOpt = kvMap.get("SN") + val oOpt = kvMap.get("O") + + val caOpt = kvMap.get("CA") + val cnOpt = kvMap.get("CN") + + val (caFinal, cnFinal): (String, String) = (caOpt, cnOpt) match { + case (Some(caRaw), Some(cnRaw)) => + // Both CA and CN are present: use them as-is + (caRaw, cnRaw) + + case (Some(caRaw), None) if caRaw.startsWith("CN=") => + // Special case: CA=CN=... → set both CA and CN to value after CN= + val value = caRaw.stripPrefix("CN=") + (value, value) - (kvMap.get("SN"), caValue, kvMap.get("O")) match { - case (Some(sn), Some(ca), Some(o)) => - Right(ParsedKeyId(sn, ca, o)) case _ => - Left(s"Invalid or missing fields in keyId: found keys ${kvMap.keys.mkString(", ")}") + return Left(s"Missing mandatory 'CN' field or invalid CA format: found keys ${kvMap.keys.mkString(", ")}") + } + + (snOpt, oOpt) match { + case (Some(sn), Some(o)) => + Right(ParsedKeyId(sn, caFinal, cnFinal, o)) + case _ => + Left(s"Missing mandatory 'SN' or 'O' field: found keys ${kvMap.keys.mkString(", ")}") } } @@ -46,21 +67,57 @@ object BerlinGroupSignatureHeaderParser { } yield ParsedSignature(keyId, headers, sig) } + /** + * Detect and match incoming SN as decimal or hex against certificate serial number. + */ + def doesSerialNumberMatch(incomingSn: String, certSerial: java.math.BigInteger): Boolean = { + try { + val incomingAsDecimal = new java.math.BigInteger(incomingSn, 10) + if (incomingAsDecimal == certSerial) { + logger.debug(s"SN matched in decimal") + return true + } + } catch { + case _: NumberFormatException => + logger.debug(s"Incoming SN is not valid decimal: $incomingSn") + } + + try { + val incomingAsHex = new java.math.BigInteger(incomingSn, 16) + if (incomingAsHex == certSerial) { + logger.debug(s"SN matched in hex") + return true + } + } catch { + case _: NumberFormatException => + logger.debug(s"Incoming SN is not valid hex: $incomingSn") + } + + false + } + // Example usage def main(args: Array[String]): Unit = { - val header = - """keyId="CA=CN=MAIB Prisacaru Sergiu (Test), SN=43A, O=MAIB", headers="digest date x-request-id", signature="abc123+==" """ + val testHeaders = List( + """keyId="CA=CN=MAIB Prisacaru Sergiu (Test), SN=43A, O=MAIB", headers="digest date x-request-id", signature="abc123+=="""", + """keyId="CA=SomeCAValue, CN=SomeCNValue, SN=43A, O=MAIB", headers="digest date x-request-id", signature="def456+=="""", + """keyId="CA=MissingCN, SN=43A, O=MAIB", headers="digest date x-request-id", signature="should_fail"""" + ) - parseSignatureHeader(header) match { - case Right(parsed) => - println("Parsed Signature Header:") - println(s"SN: ${parsed.keyId.sn}") - println(s"CA: ${parsed.keyId.ca}") - println(s"O: ${parsed.keyId.o}") - println(s"Headers: ${parsed.headers.mkString(", ")}") - println(s"Signature: ${parsed.signature}") - case Left(error) => - println(s"Error: $error") + testHeaders.foreach { header => + println(s"\nParsing header:\n$header") + parseSignatureHeader(header) match { + case Right(parsed) => + println("Parsed Signature Header:") + println(s" SN: ${parsed.keyId.sn}") + println(s" CA: ${parsed.keyId.ca}") + println(s" CN: ${parsed.keyId.cn}") + println(s" O: ${parsed.keyId.o}") + println(s" Headers: ${parsed.headers.mkString(", ")}") + println(s" Signature: ${parsed.signature}") + case Left(error) => + println(s"Error: $error") + } } } } From aa2ab13c0280d6420d9e8f49315edfffe4fb9180 Mon Sep 17 00:00:00 2001 From: karmaking Date: Tue, 8 Jul 2025 17:21:50 +0200 Subject: [PATCH 20/33] add comment to sample props --- obp-api/src/main/resources/props/sample.props.template | 2 ++ 1 file changed, 2 insertions(+) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 462ff7a62..f2baea7a1 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -365,6 +365,8 @@ BankMockKey=change_me ##################################################################################### ## Web interface configuration +## do not put sensitive information in any webui props, as these can be retrieved by a public endpoint. + ## IMPLEMENTING BANK SPECIFIC BRANDING ON ONE OBP INSTANCE ######################## # Note, you can specify bank specific branding by appending _FOR_BRAND_ to the standard props names # e.g. From 8c0b07f1c03e99513c89a8fc201bd9924a2ed924 Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 9 Jul 2025 14:43:37 +0200 Subject: [PATCH 21/33] feature/OBPv510 add new endpoint getWebUiProps --- .../scala/code/api/v5_1_0/APIMethods510.scala | 65 ++++++++++++ .../code/api/v5_1_0/WebUiPropsTest.scala | 98 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index d9ed88566..f5ae52d98 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -43,6 +43,7 @@ import code.util.Helper import code.util.Helper.ObpS import code.views.Views import code.views.system.{AccountAccess, ViewDefinition} +import code.webuiprops.{MappedWebUiPropsProvider, WebUiPropsCommons} import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model._ @@ -5145,6 +5146,70 @@ trait APIMethods510 { } } + + resourceDocs += ResourceDoc( + getWebUiProps, + implementedInApiVersion, + nameOf(getWebUiProps), + "GET", + "/webui_props", + "Get WebUiProps", + s""" + | + |Get the all WebUiProps key values, those props key with "webui_" can be stored in DB, this endpoint get all from DB. + | + |url query parameter: + |active: It must be a boolean string. and If active = true, it will show + | combination of explicit (inserted) + implicit (default) method_routings. + | + |eg: + |${getObpApiRoot}/v5.1.0/webui_props + |${getObpApiRoot}/v5.1.0/webui_props?active=true + | + |""", + EmptyBody, + ListResult( + "webui_props", + (List(WebUiPropsCommons("webui_api_explorer_url", "https://apiexplorer.openbankproject.com", Some("web-ui-props-id")))) + ) + , + List( + UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagWebUiProps) + ) + lazy val getWebUiProps: OBPEndpoint = { + case "webui_props":: Nil JsonGet req => { + cc => implicit val ec = EndpointContext(Some(cc)) + val active = ObpS.param("active").getOrElse("false") + for { + (Full(u), callContext) <- authenticatedAccess(cc) + invalidMsg = s"""$InvalidFilterParameterFormat `active` must be a boolean, but current `active` value is: ${active} """ + isActived <- NewStyle.function.tryons(invalidMsg, 400, callContext) { + active.toBoolean + } + explicitWebUiProps <- Future{ MappedWebUiPropsProvider.getAll() } + implicitWebUiPropsRemovedDuplicated = if(isActived){ + val implicitWebUiProps = getWebUIPropsPairs.map(webUIPropsPairs=>WebUiPropsCommons(webUIPropsPairs._1, webUIPropsPairs._2, webUiPropsId= Some("default"))) + if(explicitWebUiProps.nonEmpty){ + //get the same name props in the `implicitWebUiProps` + val duplicatedProps : List[WebUiPropsCommons]= explicitWebUiProps.map(explicitWebUiProp => implicitWebUiProps.filter(_.name == explicitWebUiProp.name)).flatten + //remove the duplicated fields from `implicitWebUiProps` + implicitWebUiProps diff duplicatedProps + } + else implicitWebUiProps.distinct + } else { + List.empty[WebUiPropsCommons] + } + } yield { + val listCommons: List[WebUiPropsCommons] = explicitWebUiProps ++ implicitWebUiPropsRemovedDuplicated + (ListResult("webui_props", listCommons), HttpCode.`200`(callContext)) + } + } + } + } } diff --git a/obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala new file mode 100644 index 000000000..d7a3aac8d --- /dev/null +++ b/obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala @@ -0,0 +1,98 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2019, TESOBE GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH +Osloerstrasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + */ +package code.api.v5_1_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole._ +import code.api.util.ErrorMessages._ +import code.api.v5_1_0.OBPAPI5_1_0.Implementations5_1_0 +import code.entitlement.Entitlement +import code.webuiprops.WebUiPropsCommons +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json.Serialization.write +import org.scalatest.Tag + + +class WebUiPropsTest extends V510ServerSetup { + + /** + * Test tags + * Example: To run tests with tag "getPermissions": + * mvn test -D tagsToInclude + * + * This is made possible by the scalatest maven plugin + */ + object VersionOfApi extends Tag(ApiVersion.v5_1_0.toString) + object ApiEndpoint extends Tag(nameOf(Implementations5_1_0.getWebUiProps)) + + val rightEntity = WebUiPropsCommons("webui_api_explorer_url", "https://apiexplorer.openbankproject.com") + val wrongEntity = WebUiPropsCommons("hello_api_explorer_url", "https://apiexplorer.openbankproject.com") // name not start with "webui_" + + + feature("Get WebUiPropss v5.1.0 ") { + scenario("We will call the endpoint without user credentials", VersionOfApi) { + When("We make a request v5.1.0") + val request510 = (v5_1_0_Request / "webui_props").GET + val response510 = makeGetRequest(request510) + Then("We should get a 401") + response510.code should equal(401) + And("error should be " + UserNotLoggedIn) + response510.body.extract[ErrorMessage].message should equal (UserNotLoggedIn) + } + + scenario("successful case", VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateWebUiProps.toString) + When("We make a request v3.1.0") + val request510 = (v5_1_0_Request / "management" / "webui_props").POST <@(user1) + val response510 = makePostRequest(request510, write(rightEntity)) + Then("We should get a 201") + response510.code should equal(201) + val customerJson = response510.body.extract[WebUiPropsCommons] + + val requestGet510 = (v5_1_0_Request / "webui_props").GET <@(user1) + val responseGet510 = makeGetRequest(requestGet510) + Then("We should get a 200") + responseGet510.code should equal(200) + val json = responseGet510.body \ "webui_props" + val webUiPropssGetJson = json.extract[List[WebUiPropsCommons]] + + webUiPropssGetJson.size should be (1) + + val requestGet510AddedQueryParameter = requestGet510.addQueryParameter("active", "true") + val responseGet510AddedQueryParameter = makeGetRequest(requestGet510AddedQueryParameter) + Then("We should get a 200") + responseGet510AddedQueryParameter.code should equal(200) + val responseJson = responseGet510AddedQueryParameter.body \ "webui_props" + val responseGet510AddedQueryParameterJson = responseJson.extract[List[WebUiPropsCommons]] + responseGet510AddedQueryParameterJson.size >1 should be (true) + + } + } + + +} From b9986b2969c07097f8740d15d2cd299f408606e5 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 9 Jul 2025 14:56:22 +0200 Subject: [PATCH 22/33] refactor/update finishDate to be an Option type for better handling of missing values --- .../api/ResourceDocs1_4_0/MessageDocsSwaggerDefinitions.scala | 2 +- .../bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala | 4 ++-- .../code/bankconnectors/rest/RestConnector_vMar2019.scala | 4 ++-- .../storedprocedure/StoredProcedureConnector_vDec2019.scala | 4 ++-- obp-api/src/main/scala/code/management/ImporterAPI.scala | 4 +++- obp-api/src/main/scala/code/model/View.scala | 2 +- .../src/main/scala/code/transaction/MappedTransaction.scala | 2 +- .../test/scala/code/api/v2_1_0/SandboxDataLoadingTest.scala | 2 +- obp-api/src/test/scala/code/management/ImporterTest.scala | 2 +- .../scala/com/openbankproject/commons/model/CommonModel.scala | 2 +- 10 files changed, 15 insertions(+), 13 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/MessageDocsSwaggerDefinitions.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/MessageDocsSwaggerDefinitions.scala index 356864a65..e7dc68c5c 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/MessageDocsSwaggerDefinitions.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/MessageDocsSwaggerDefinitions.scala @@ -230,7 +230,7 @@ object MessageDocsSwaggerDefinitions currency = currencyExample.value, description = Some(transactionDescriptionExample.value), startDate = DateWithDayExampleObject, - finishDate = DateWithDayExampleObject, + finishDate = Some(DateWithDayExampleObject), balance = BigDecimal(balanceAmountExample.value), status = transactionStatusExample.value, ) diff --git a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala index bc52ac497..d03236dd0 100644 --- a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala +++ b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala @@ -1550,7 +1550,7 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { currency=currencyExample.value, description=Some(transactionDescriptionExample.value), startDate=toDate(transactionStartDateExample), - finishDate=toDate(transactionFinishDateExample), + finishDate=Some(toDate(transactionFinishDateExample)), balance=BigDecimal(balanceExample.value), status=transactionStatusExample.value ))) @@ -1685,7 +1685,7 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { currency=currencyExample.value, description=Some(transactionDescriptionExample.value), startDate=toDate(transactionStartDateExample), - finishDate=toDate(transactionFinishDateExample), + finishDate=Some(toDate(transactionFinishDateExample)), balance=BigDecimal(balanceExample.value), status=transactionStatusExample.value)) ), diff --git a/obp-api/src/main/scala/code/bankconnectors/rest/RestConnector_vMar2019.scala b/obp-api/src/main/scala/code/bankconnectors/rest/RestConnector_vMar2019.scala index 5ef91390e..6bfee72bd 100644 --- a/obp-api/src/main/scala/code/bankconnectors/rest/RestConnector_vMar2019.scala +++ b/obp-api/src/main/scala/code/bankconnectors/rest/RestConnector_vMar2019.scala @@ -1498,7 +1498,7 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { currency=currencyExample.value, description=Some(transactionDescriptionExample.value), startDate=toDate(transactionStartDateExample), - finishDate=toDate(transactionFinishDateExample), + finishDate=Some(toDate(transactionFinishDateExample)), balance=BigDecimal(balanceExample.value), status=transactionStatusExample.value))) ), @@ -1632,7 +1632,7 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { currency=currencyExample.value, description=Some(transactionDescriptionExample.value), startDate=toDate(transactionStartDateExample), - finishDate=toDate(transactionFinishDateExample), + finishDate=Some(toDate(transactionFinishDateExample)), balance=BigDecimal(balanceExample.value), status=transactionStatusExample.value)) ), diff --git a/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureConnector_vDec2019.scala b/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureConnector_vDec2019.scala index 65a541190..0ab7b583b 100644 --- a/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureConnector_vDec2019.scala +++ b/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureConnector_vDec2019.scala @@ -1479,7 +1479,7 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { currency=currencyExample.value, description=Some(transactionDescriptionExample.value), startDate=toDate(transactionStartDateExample), - finishDate=toDate(transactionFinishDateExample), + finishDate=Some(toDate(transactionFinishDateExample)), balance=BigDecimal(balanceExample.value), status=transactionStatusExample.value))) ), @@ -1613,7 +1613,7 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { currency=currencyExample.value, description=Some(transactionDescriptionExample.value), startDate=toDate(transactionStartDateExample), - finishDate=toDate(transactionFinishDateExample), + finishDate=Some(toDate(transactionFinishDateExample)), balance=BigDecimal(balanceExample.value), status=transactionStatusExample.value)) ), diff --git a/obp-api/src/main/scala/code/management/ImporterAPI.scala b/obp-api/src/main/scala/code/management/ImporterAPI.scala index c29b5f37d..3a0fa9e28 100644 --- a/obp-api/src/main/scala/code/management/ImporterAPI.scala +++ b/obp-api/src/main/scala/code/management/ImporterAPI.scala @@ -1,8 +1,10 @@ package code.management + import java.util.Date import code.api.util.ErrorMessages._ import code.api.util.{APIUtil, CustomJsonFormats} +import code.api.util.APIUtil.DateWithMsExampleObject import code.bankconnectors.{Connector, LocalMappedConnectorInternal} import code.tesobe.ErrorMessage import code.util.Helper.{MdcLoggable, ObpS} @@ -93,7 +95,7 @@ object ImporterAPI extends RestHelper with MdcLoggable { val detailsJson = JObject(List( JField("type_en", JString(t.transactionType)), JField("type", JString(t.transactionType)), JField("posted", JString(formatDate(t.startDate))), - JField("completed", JString(formatDate(t.finishDate))), + JField("completed", JString(formatDate(t.finishDate.getOrElse(DateWithMsExampleObject)))), JField("other_data", JString("")), JField("new_balance", JObject(List( JField("currency", JString(t.currency)), JField("amount", JString(t.balance.toString))))), diff --git a/obp-api/src/main/scala/code/model/View.scala b/obp-api/src/main/scala/code/model/View.scala index dfc822818..1c86969d7 100644 --- a/obp-api/src/main/scala/code/model/View.scala +++ b/obp-api/src/main/scala/code/model/View.scala @@ -160,7 +160,7 @@ case class ViewExtended(val view: View) { else None val transactionFinishDate = - if (view.canSeeTransactionFinishDate) Some(transaction.finishDate) + if (view.canSeeTransactionFinishDate) transaction.finishDate else None val transactionBalance = diff --git a/obp-api/src/main/scala/code/transaction/MappedTransaction.scala b/obp-api/src/main/scala/code/transaction/MappedTransaction.scala index 5ff7ab1bb..9edfcc0a4 100644 --- a/obp-api/src/main/scala/code/transaction/MappedTransaction.scala +++ b/obp-api/src/main/scala/code/transaction/MappedTransaction.scala @@ -154,7 +154,7 @@ class MappedTransaction extends LongKeyedMapper[MappedTransaction] with IdPK wit transactionCurrency, transactionDescription, tStartDate.get, - tFinishDate.get, + Some(tFinishDate.get), newBalance, status.get)) } diff --git a/obp-api/src/test/scala/code/api/v2_1_0/SandboxDataLoadingTest.scala b/obp-api/src/test/scala/code/api/v2_1_0/SandboxDataLoadingTest.scala index 52a81616a..69b603677 100644 --- a/obp-api/src/test/scala/code/api/v2_1_0/SandboxDataLoadingTest.scala +++ b/obp-api/src/test/scala/code/api/v2_1_0/SandboxDataLoadingTest.scala @@ -334,7 +334,7 @@ class SandboxDataLoadingTest extends FlatSpec with SendServerRequests with Match } foundTransaction.startDate.getTime should equal(toDate(transaction.details.posted).getTime) - foundTransaction.finishDate.getTime should equal(toDate(transaction.details.completed).getTime) + foundTransaction.finishDate.head.getTime should equal(toDate(transaction.details.completed).getTime) //a counterparty should exist val otherAcc = foundTransaction.otherAccount diff --git a/obp-api/src/test/scala/code/management/ImporterTest.scala b/obp-api/src/test/scala/code/management/ImporterTest.scala index 1b7b8d9a9..acad983da 100644 --- a/obp-api/src/test/scala/code/management/ImporterTest.scala +++ b/obp-api/src/test/scala/code/management/ImporterTest.scala @@ -145,7 +145,7 @@ class ImporterTest extends ServerSetup with MdcLoggable with DefaultConnectorTes //compare time as a long to avoid issues comparing Dates, e.g. java.util.Date vs java.sql.Date t.startDate.getTime should equal(importJsonDateFormat.parse(startDate).getTime) - t.finishDate.getTime should equal(importJsonDateFormat.parse(endDate).getTime) + t.finishDate.head.getTime should equal(importJsonDateFormat.parse(endDate).getTime) } scenario("Attempting to import transactions without using a secret key") { diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala index ba46de514..5a300bf6a 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala @@ -1138,7 +1138,7 @@ case class Transaction( // The date the transaction was initiated startDate : Date, // The date when the money finished changing hands - finishDate : Date, + finishDate : Option[Date], //the new balance for the bank account balance : BigDecimal, status: String From a7b7db078a79896cb795184b4662f5b7020098a6 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 9 Jul 2025 15:20:13 +0200 Subject: [PATCH 23/33] test/fixed test --- .../RestConnector_vMar2019_frozen_meta_data | Bin 123934 -> 123942 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data b/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data index 883c5289796f15f95ccafa8c687d720d3d5de1e8..a802ceacdccc8c15da1964eb466c7446957eae1b 100644 GIT binary patch delta 21 dcmbPtf_>Qu_J%Et4^K~Kijv&^=`^E`1ORa`355Uv delta 22 ecmZ2>f_>fz_J%Et4^K}o;APa`E^&sDPXYjS<_O9F From 2e2b9ec5df651ea81a93dde1dc6d01634732214f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 9 Jul 2025 15:40:16 +0200 Subject: [PATCH 24/33] feature/Create Get well known url endpoint --- .../scala/code/api/util/KeycloakAdmin.scala | 4 +-- .../scala/code/api/v5_1_0/APIMethods510.scala | 32 +++++++++++++++++++ .../code/api/v5_1_0/JSONFactory5.1.0.scala | 3 ++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/KeycloakAdmin.scala b/obp-api/src/main/scala/code/api/util/KeycloakAdmin.scala index 7ff765077..fafb6a3c2 100644 --- a/obp-api/src/main/scala/code/api/util/KeycloakAdmin.scala +++ b/obp-api/src/main/scala/code/api/util/KeycloakAdmin.scala @@ -14,7 +14,7 @@ object KeycloakAdmin { // Initialize Logback logger private val logger = LoggerFactory.getLogger("okhttp3") - val integrateWithKeycloak = APIUtil.getPropsAsBoolValue("integrate_with_keycloak", defaultValue = false) + val integrateWithKeycloak: Boolean = APIUtil.getPropsAsBoolValue("integrate_with_keycloak", defaultValue = false) // Define variables (replace with actual values) private val keycloakHost = Keycloak.keycloakHost private val realm = APIUtil.getPropsValue(nameOfProperty = "oauth2.keycloak.realm", "master") @@ -30,7 +30,7 @@ object KeycloakAdmin { builder.build() } // Create OkHttp client with logging - val client = createHttpClientWithLogback() + val client: OkHttpClient = createHttpClientWithLogback() def createKeycloakConsumer(consumer: Consumer): Box[Boolean] = { val isPublic = diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index d9ed88566..08b9ca237 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -2,6 +2,7 @@ package code.api.v5_1_0 import code.api.Constant +import code.api.OAuth2Login.Keycloak import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{ConsentAccessAccountsJson, ConsentAccessJson} import code.api.util.APIUtil._ @@ -136,6 +137,37 @@ trait APIMethods510 { } + staticResourceDocs += ResourceDoc( + getOAuth2ServerWellKnown, + implementedInApiVersion, + "getOAuth2ServerWellKnown", + "GET", + "/well-known", + "Get Well Known URIs", + """Get the OAuth2 server's public Well Known URIs. + | + """.stripMargin, + EmptyBody, + oAuth2ServerJwksUrisJson, + List( + UnknownError + ), + List(apiTagApi)) + + lazy val getOAuth2ServerWellKnown: OBPEndpoint = { + case "well-known" :: Nil JsonGet _ => { + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- anonymousAccess(cc) + } yield { + val keycloak: WellKnownUriJsonV510 = WellKnownUriJsonV510("keycloak", Keycloak.wellKnownOpenidConfiguration.toURL.toString) + (WellKnownUrisJsonV510(List(keycloak)), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( regulatedEntities, implementedInApiVersion, diff --git a/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala b/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala index e4f45ca05..9b1a319cf 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala @@ -58,6 +58,9 @@ import java.util.Date import scala.util.Try +case class WellKnownUrisJsonV510(well_known_uris: List[WellKnownUriJsonV510]) +case class WellKnownUriJsonV510(provider: String, url: String) + case class RegulatedEntityAttributeRequestJsonV510( name: String, attribute_type: String, From 74e39af6af3c8f2c75554f52ea3682f3700e5d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 10 Jul 2025 10:20:46 +0200 Subject: [PATCH 25/33] feature/Validate request header date format for all BG endpoints --- .../berlin/group/v1_3/BgSpecValidation.scala | 18 +++++++++++++++++- .../code/api/util/BerlinGroupCheck.scala | 19 ++++++++++++++++++- .../code/api/util/BerlinGroupError.scala | 1 + .../scala/code/api/util/ErrorMessages.scala | 1 + 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala index 082c2fcd5..5cc1939e2 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala @@ -3,9 +3,10 @@ package code.api.berlin.group.v1_3 import code.api.util.APIUtil.DateWithDayFormat import code.api.util.ErrorMessages.InvalidDateFormat +import java.text.SimpleDateFormat import java.time.format.{DateTimeFormatter, DateTimeParseException} import java.time.{LocalDate, ZoneId} -import java.util.Date +import java.util.{Date, Locale} object BgSpecValidation { @@ -53,6 +54,21 @@ object BgSpecValidation { } } + + // Define the correct RFC 7231 date format (IMF-fixdate) + private val dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH) + // Force timezone to be GMT + dateFormat.setLenient(false) + def isValidRfc7231Date(dateStr: String): Boolean = { + try { + val parsedDate = dateFormat.parse(dateStr) + // Check that the timezone part is exactly "GMT" + dateStr.endsWith(" GMT") + } catch { + case _: Exception => false + } + } + // Example usage def main(args: Array[String]): Unit = { val testDates = Seq( 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 0e0962a39..25000f36c 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -1,6 +1,7 @@ package code.api.util import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.BgSpecValidation import code.api.{APIFailureNewStyle, RequestHeader} import code.api.util.APIUtil.{OBPReturnType, fullBoxOrException} import code.api.util.BerlinGroupSigning.{getCertificateFromTppSignatureCertificate, getHeaderValue} @@ -62,7 +63,22 @@ object BerlinGroupCheck extends MdcLoggable { berlinGroupMandatoryHeaders.filterNot(headerMap.contains) } - val resultWithMissingHeaderCheck: Option[(Box[User], Option[CallContext])] = + val resultWithMissingHeaderCheck: Option[(Box[User], Option[CallContext])] = { + val date: Option[String] = headerMap.get(RequestHeader.Date.toLowerCase).flatMap(_.values.headOption) + if (!BgSpecValidation.isValidRfc7231Date(date.get)) { + val message = ErrorMessages.NotValidRfc7231Date + Some( + ( + fullBoxOrException( + Empty ~> APIFailureNewStyle(message, 400, forwardResult._2.map(_.toLight)) + ), + forwardResult._2 + ) + ) + } else None + } + + val resultWithWrongDateHeaderCheck: Option[(Box[User], Option[CallContext])] = if (missingHeaders.nonEmpty) { val message = if (missingHeaders.size == 1) ErrorMessages.MissingMandatoryBerlinGroupHeaders.replace("headers", "header") @@ -170,6 +186,7 @@ object BerlinGroupCheck extends MdcLoggable { // Chain validation steps resultWithMissingHeaderCheck + .orElse(resultWithMissingHeaderCheck) .orElse(resultWithInvalidRequestIdCheck) .orElse(resultWithRequestIdUsedTwiceCheck) .orElse(resultWithInvalidSignatureHeaderCheck) diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala index df6875c6f..23c85214a 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala @@ -97,6 +97,7 @@ object BerlinGroupError { case "400" if message.contains("OBP-20254") => "FORMAT_ERROR" case "400" if message.contains("OBP-20255") => "FORMAT_ERROR" case "400" if message.contains("OBP-20256") => "FORMAT_ERROR" + case "400" if message.contains("OBP-20257") => "FORMAT_ERROR" case "400" if message.contains("OBP-20251") => "FORMAT_ERROR" case "400" if message.contains("OBP-20088") => "FORMAT_ERROR" case "400" if message.contains("OBP-20089") => "FORMAT_ERROR" 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 f4601288a..af993b5b6 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -278,6 +278,7 @@ object ErrorMessages { val InvalidSignatureHeader = "OBP-20254: Invalid Signature header. " val InvalidRequestIdValueAlreadyUsed = "OBP-20255: Request Id value already used. " val InvalidConsentIdUsage = "OBP-20256: Consent-Id must not be used for this API Endpoint. " + val NotValidRfc7231Date = "OBP-20257: Request header Date is not in accordance with RFC 7231 " // X.509 val X509GeneralError = "OBP-20300: PEM Encoded Certificate issue." From 3edb05a131ba4919455b24603d9cb6a3930936d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 10 Jul 2025 11:12:25 +0200 Subject: [PATCH 26/33] feature/Make field algorithm mandatory at BG signature header --- .../src/main/scala/code/api/util/BerlinGroupCheck.scala | 1 + .../code/api/util/BerlinGroupSignatureHeaderParser.scala | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) 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 25000f36c..ea5ed1fbb 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -139,6 +139,7 @@ object BerlinGroupCheck extends MdcLoggable { logger.debug(s" CN: ${parsed.keyId.cn}") logger.debug(s" O: ${parsed.keyId.o}") logger.debug(s" Headers: ${parsed.headers.mkString(", ")}") + logger.debug(s" Algorithm: ${parsed.algorithm}") logger.debug(s" Signature: ${parsed.signature}") val certificate = getCertificateFromTppSignatureCertificate(reqHeaders) diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupSignatureHeaderParser.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupSignatureHeaderParser.scala index bba9fd298..2336f67e0 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupSignatureHeaderParser.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupSignatureHeaderParser.scala @@ -6,7 +6,7 @@ object BerlinGroupSignatureHeaderParser extends MdcLoggable { case class ParsedKeyId(sn: String, ca: String, cn: String, o: String) - case class ParsedSignature(keyId: ParsedKeyId, headers: List[String], signature: String) + case class ParsedSignature(keyId: ParsedKeyId, algorithm: String, headers: List[String], signature: String) def parseQuotedValue(value: String): String = value.stripPrefix("\"").stripSuffix("\"").trim @@ -50,7 +50,7 @@ object BerlinGroupSignatureHeaderParser extends MdcLoggable { } def parseSignatureHeader(header: String): Either[String, ParsedSignature] = { - val fields = header.split(",(?=\\s*(keyId|headers|signature)=)").map(_.trim) + val fields = header.split(",(?=\\s*(keyId|headers|algorithm|signature)=)").map(_.trim) val kvMap = fields.flatMap { field => field.split("=", 2) match { @@ -64,7 +64,8 @@ object BerlinGroupSignatureHeaderParser extends MdcLoggable { keyId <- parseKeyIdField(keyIdStr) headers <- kvMap.get("headers").map(_.split("\\s+").toList).toRight("Missing 'headers' field") sig <- kvMap.get("signature").toRight("Missing 'signature' field") - } yield ParsedSignature(keyId, headers, sig) + algorithm <- kvMap.get("algorithm").toRight("Missing 'algorithm' field") + } yield ParsedSignature(keyId, algorithm, headers, sig) } /** From c48727fe8f7a54b555cd47d16817a9ea16096fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 10 Jul 2025 14:51:50 +0200 Subject: [PATCH 27/33] docfix/Improve consent scheduler logging --- .../scala/code/consent/ConsentProvider.scala | 7 ++++- .../scala/code/consent/MappedConsent.scala | 2 ++ .../code/scheduler/ConsentScheduler.scala | 31 +++++++++++++++---- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/obp-api/src/main/scala/code/consent/ConsentProvider.scala b/obp-api/src/main/scala/code/consent/ConsentProvider.scala index 9f5ae7a3e..ecb78522e 100644 --- a/obp-api/src/main/scala/code/consent/ConsentProvider.scala +++ b/obp-api/src/main/scala/code/consent/ConsentProvider.scala @@ -184,9 +184,14 @@ trait ConsentTrait { def transactionToDateTime: Date /** - * this will be a UUID later. now only use the primacyKey.toString for it. + * this will be a UUID later. now only use the primacyKey.toString for it. */ def consentReferenceId: String + + /** + * Note about any important consent information. + */ + def note: String } object ConsentStatus extends Enumeration { diff --git a/obp-api/src/main/scala/code/consent/MappedConsent.scala b/obp-api/src/main/scala/code/consent/MappedConsent.scala index 2514d9df5..21f5bca8a 100644 --- a/obp-api/src/main/scala/code/consent/MappedConsent.scala +++ b/obp-api/src/main/scala/code/consent/MappedConsent.scala @@ -397,6 +397,7 @@ class MappedConsent extends ConsentTrait with LongKeyedMapper[MappedConsent] wit object mTransactionFromDateTime extends MappedDateTime(this) object mTransactionToDateTime extends MappedDateTime(this) object mStatusUpdateDateTime extends MappedDateTime(this) + object mNote extends MappedText(this) override def consentId: String = mConsentId.get override def userId: String = mUserId.get @@ -426,6 +427,7 @@ class MappedConsent extends ConsentTrait with LongKeyedMapper[MappedConsent] wit override def creationDateTime= createdAt.get override def statusUpdateDateTime= mStatusUpdateDateTime.get override def consentReferenceId = id.get.toString + override def note = mNote.get } diff --git a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala index 7d8113a60..d793c12f4 100644 --- a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala +++ b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala @@ -8,11 +8,15 @@ import com.openbankproject.commons.util.{ApiStandards, ApiVersion} import net.liftweb.common.Full import net.liftweb.mapper.{By, By_<} +import java.text.SimpleDateFormat import java.util.Date +import java.util.Locale import scala.util.{Failure, Success, Try} object ConsentScheduler extends MdcLoggable { + val dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.ENGLISH) + def currentDate = dateFormat.format(new Date()) // Starts multiple scheduled tasks with different intervals def startAll(): Unit = { @@ -61,8 +65,13 @@ object ConsentScheduler extends MdcLoggable { outdatedConsents.foreach { consent => Try { - consent.mStatus(ConsentStatus.rejected.toString).save - logger.warn(s"|---> Changed status to ${ConsentStatus.rejected.toString} for consent ID: ${consent.id}") + val message = s"|---> Changed status from ${consent.status} to ${ConsentStatus.rejected} for consent ID: ${consent.id}" + consent + .mStatus(ConsentStatus.rejected.toString) + .mNote(s"$currentDate\n$message") + .mStatusUpdateDateTime(new Date()) + .save + logger.warn(message) } match { case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex) case Success(_) => // Already logged @@ -87,8 +96,13 @@ object ConsentScheduler extends MdcLoggable { expiredConsents.foreach { consent => Try { - consent.mStatus(ConsentStatus.expired.toString).save - logger.warn(s"|---> Changed status to ${ConsentStatus.expired.toString} for consent ID: ${consent.id}") + val message = s"|---> Changed status from ${consent.status} to ${ConsentStatus.expired} for consent ID: ${consent.id}" + consent + .mStatus(ConsentStatus.expired.toString) + .mNote(s"$currentDate\n$message") + .mStatusUpdateDateTime(new Date()) + .save + logger.warn(message) } match { case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex) case Success(_) => // Already logged @@ -113,8 +127,13 @@ object ConsentScheduler extends MdcLoggable { expiredConsents.foreach { consent => Try { - consent.mStatus(ConsentStatus.EXPIRED.toString).save - logger.warn(s"|---> Changed status to ${ConsentStatus.EXPIRED.toString} for consent ID: ${consent.id}") + val message = s"|---> Changed status from ${consent.status} to ${ConsentStatus.EXPIRED.toString} for consent ID: ${consent.id}" + consent + .mStatus(ConsentStatus.EXPIRED.toString) + .mNote(s"$currentDate\n$message") + .mStatusUpdateDateTime(new Date()) + .save + logger.warn(message) } match { case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex) case Success(_) => // Already logged From 557743399dbba95d888298519451191f7ce8a201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 10 Jul 2025 16:06:51 +0200 Subject: [PATCH 28/33] test/Fix failed tests --- obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ea5ed1fbb..2479e5eb6 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -65,7 +65,7 @@ object BerlinGroupCheck extends MdcLoggable { val resultWithMissingHeaderCheck: Option[(Box[User], Option[CallContext])] = { val date: Option[String] = headerMap.get(RequestHeader.Date.toLowerCase).flatMap(_.values.headOption) - if (!BgSpecValidation.isValidRfc7231Date(date.get)) { + if (date.isDefined && !BgSpecValidation.isValidRfc7231Date(date.get)) { val message = ErrorMessages.NotValidRfc7231Date Some( ( From 8468e494ccb16ecf0765ce0619e49e3dc1bba7a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 10 Jul 2025 16:17:02 +0200 Subject: [PATCH 29/33] refactor/Put date variables at common place --- .../scala/code/api/berlin/group/v1_3/BgSpecValidation.scala | 3 ++- obp-api/src/main/scala/code/api/util/APIUtil.scala | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala index 5cc1939e2..18df46010 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala @@ -1,6 +1,7 @@ package code.api.berlin.group.v1_3 import code.api.util.APIUtil.DateWithDayFormat +import code.api.util.APIUtil.rfc7231Date import code.api.util.ErrorMessages.InvalidDateFormat import java.text.SimpleDateFormat @@ -56,7 +57,7 @@ object BgSpecValidation { // Define the correct RFC 7231 date format (IMF-fixdate) - private val dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH) + private val dateFormat = rfc7231Date // Force timezone to be GMT dateFormat.setLenient(false) def isValidRfc7231Date(dateStr: String): Boolean = { diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 379e587e0..ff620b9a8 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -104,7 +104,7 @@ import java.security.AccessControlException import java.text.{ParsePosition, SimpleDateFormat} import java.util.concurrent.ConcurrentHashMap import java.util.regex.Pattern -import java.util.{Calendar, Date, UUID} +import java.util.{Calendar, Date, Locale, UUID} import scala.collection.JavaConverters._ import scala.collection.immutable.{List, Nil} import scala.collection.mutable @@ -132,6 +132,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ val DateWithSecondsFormat = new SimpleDateFormat(DateWithSeconds) val DateWithMsFormat = new SimpleDateFormat(DateWithMs) val DateWithMsRollbackFormat = new SimpleDateFormat(DateWithMsAndTimeZoneOffset) + val rfc7231Date = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH) val DateWithYearExampleString: String = "1100" val DateWithMonthExampleString: String = "1100-01" From 3b37c6e44307823699090a93351310c127bb7492 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 10 Jul 2025 16:24:42 +0200 Subject: [PATCH 30/33] refactor/tweaked the WebUiProps URL --- .../src/main/scala/code/api/v5_1_0/APIMethods510.scala | 10 +++++----- .../test/scala/code/api/v5_1_0/WebUiPropsTest.scala | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index f5ae52d98..51305574d 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -5152,7 +5152,7 @@ trait APIMethods510 { implementedInApiVersion, nameOf(getWebUiProps), "GET", - "/webui_props", + "/webui-props", "Get WebUiProps", s""" | @@ -5163,13 +5163,13 @@ trait APIMethods510 { | combination of explicit (inserted) + implicit (default) method_routings. | |eg: - |${getObpApiRoot}/v5.1.0/webui_props - |${getObpApiRoot}/v5.1.0/webui_props?active=true + |${getObpApiRoot}/v5.1.0/webui-props + |${getObpApiRoot}/v5.1.0/webui-props?active=true | |""", EmptyBody, ListResult( - "webui_props", + "webui-props", (List(WebUiPropsCommons("webui_api_explorer_url", "https://apiexplorer.openbankproject.com", Some("web-ui-props-id")))) ) , @@ -5181,7 +5181,7 @@ trait APIMethods510 { List(apiTagWebUiProps) ) lazy val getWebUiProps: OBPEndpoint = { - case "webui_props":: Nil JsonGet req => { + case "webui-props":: Nil JsonGet req => { cc => implicit val ec = EndpointContext(Some(cc)) val active = ObpS.param("active").getOrElse("false") for { diff --git a/obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala index d7a3aac8d..4af7ba7df 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala @@ -57,7 +57,7 @@ class WebUiPropsTest extends V510ServerSetup { feature("Get WebUiPropss v5.1.0 ") { scenario("We will call the endpoint without user credentials", VersionOfApi) { When("We make a request v5.1.0") - val request510 = (v5_1_0_Request / "webui_props").GET + val request510 = (v5_1_0_Request / "webui-props").GET val response510 = makeGetRequest(request510) Then("We should get a 401") response510.code should equal(401) @@ -74,7 +74,7 @@ class WebUiPropsTest extends V510ServerSetup { response510.code should equal(201) val customerJson = response510.body.extract[WebUiPropsCommons] - val requestGet510 = (v5_1_0_Request / "webui_props").GET <@(user1) + val requestGet510 = (v5_1_0_Request / "webui-props").GET <@(user1) val responseGet510 = makeGetRequest(requestGet510) Then("We should get a 200") responseGet510.code should equal(200) From 7298c191e28a6cd90d67b0c3cce57ea638357787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 10 Jul 2025 17:31:37 +0200 Subject: [PATCH 31/33] refactor/Put function isValidRfc7231Date into common module --- .../berlin/group/v1_3/BgSpecValidation.scala | 15 --------------- .../scala/code/api/util/BerlinGroupCheck.scala | 2 +- .../scala/code/api/util/DateTimeUtil.scala | 18 ++++++++++++++++++ 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala index 18df46010..13c589a00 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala @@ -55,21 +55,6 @@ object BgSpecValidation { } } - - // Define the correct RFC 7231 date format (IMF-fixdate) - private val dateFormat = rfc7231Date - // Force timezone to be GMT - dateFormat.setLenient(false) - def isValidRfc7231Date(dateStr: String): Boolean = { - try { - val parsedDate = dateFormat.parse(dateStr) - // Check that the timezone part is exactly "GMT" - dateStr.endsWith(" GMT") - } catch { - case _: Exception => false - } - } - // Example usage def main(args: Array[String]): Unit = { val testDates = Seq( 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 2479e5eb6..fa165a533 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -65,7 +65,7 @@ object BerlinGroupCheck extends MdcLoggable { val resultWithMissingHeaderCheck: Option[(Box[User], Option[CallContext])] = { val date: Option[String] = headerMap.get(RequestHeader.Date.toLowerCase).flatMap(_.values.headOption) - if (date.isDefined && !BgSpecValidation.isValidRfc7231Date(date.get)) { + if (date.isDefined && !DateTimeUtil.isValidRfc7231Date(date.get)) { val message = ErrorMessages.NotValidRfc7231Date Some( ( diff --git a/obp-api/src/main/scala/code/api/util/DateTimeUtil.scala b/obp-api/src/main/scala/code/api/util/DateTimeUtil.scala index 7d726ecaf..eabf58648 100644 --- a/obp-api/src/main/scala/code/api/util/DateTimeUtil.scala +++ b/obp-api/src/main/scala/code/api/util/DateTimeUtil.scala @@ -1,5 +1,7 @@ package code.api.util +import code.api.util.APIUtil.rfc7231Date + import java.time.Duration object DateTimeUtil { @@ -33,4 +35,20 @@ object DateTimeUtil { if (parts.isEmpty) "less than a second" else parts.mkString(", ") } + + // Define the correct RFC 7231 date format (IMF-fixdate) + private val dateFormat = rfc7231Date + // Force timezone to be GMT + dateFormat.setLenient(false) + + def isValidRfc7231Date(dateStr: String): Boolean = { + try { + val parsedDate = dateFormat.parse(dateStr) + // Check that the timezone part is exactly "GMT" + dateStr.endsWith(" GMT") + } catch { + case _: Exception => false + } + } + } From 547675d0aa98220690f3c23ce7779802e5404189 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 11 Jul 2025 13:38:36 +0200 Subject: [PATCH 32/33] refactor/tweaked BGv1.3 valueDate field --- .../api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala index c7778ae5b..2ad548821 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala @@ -471,7 +471,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ def createTransactionJSON(transaction : ModeratedTransaction) : TransactionJsonV13 = { val bookingDate = transaction.startDate.orNull - val valueDate = transaction.finishDate.orNull + val valueDate = if(transaction.finishDate.isDefined) Some(BgSpecValidation.formatToISODate(transaction.finishDate.orNull)) else None val creditorName = transaction.otherBankAccount.map(_.label.display).getOrElse("") val creditorAccountIban = stringOrNone(transaction.otherBankAccount.map(_.iban.getOrElse("")).getOrElse("")) @@ -502,7 +502,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ transaction.amount.get.toString() ), bookingDate = Some(BgSpecValidation.formatToISODate(bookingDate)) , - valueDate = Some(BgSpecValidation.formatToISODate(valueDate)), + valueDate = valueDate, remittanceInformationUnstructured = transaction.description ) } From b7539cb1c2d9f89a7489b0bffabab85c2754421c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 11 Jul 2025 14:56:35 +0200 Subject: [PATCH 33/33] refactor/tweaked webui endpoint --- .../main/scala/code/api/v5_1_0/APIMethods510.scala | 8 +++----- .../test/scala/code/api/v5_1_0/WebUiPropsTest.scala | 11 +---------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index 3a06b20ac..29aa14b9c 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -5206,7 +5206,6 @@ trait APIMethods510 { ) , List( - UserNotLoggedIn, UserHasMissingRoles, UnknownError ), @@ -5217,9 +5216,8 @@ trait APIMethods510 { cc => implicit val ec = EndpointContext(Some(cc)) val active = ObpS.param("active").getOrElse("false") for { - (Full(u), callContext) <- authenticatedAccess(cc) - invalidMsg = s"""$InvalidFilterParameterFormat `active` must be a boolean, but current `active` value is: ${active} """ - isActived <- NewStyle.function.tryons(invalidMsg, 400, callContext) { + invalidMsg <- Future(s"""$InvalidFilterParameterFormat `active` must be a boolean, but current `active` value is: ${active} """) + isActived <- NewStyle.function.tryons(invalidMsg, 400, cc.callContext) { active.toBoolean } explicitWebUiProps <- Future{ MappedWebUiPropsProvider.getAll() } @@ -5237,7 +5235,7 @@ trait APIMethods510 { } } yield { val listCommons: List[WebUiPropsCommons] = explicitWebUiProps ++ implicitWebUiPropsRemovedDuplicated - (ListResult("webui_props", listCommons), HttpCode.`200`(callContext)) + (ListResult("webui_props", listCommons), HttpCode.`200`(cc.callContext)) } } } diff --git a/obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala index 4af7ba7df..80606a199 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala @@ -55,15 +55,6 @@ class WebUiPropsTest extends V510ServerSetup { feature("Get WebUiPropss v5.1.0 ") { - scenario("We will call the endpoint without user credentials", VersionOfApi) { - When("We make a request v5.1.0") - val request510 = (v5_1_0_Request / "webui-props").GET - val response510 = makeGetRequest(request510) - Then("We should get a 401") - response510.code should equal(401) - And("error should be " + UserNotLoggedIn) - response510.body.extract[ErrorMessage].message should equal (UserNotLoggedIn) - } scenario("successful case", VersionOfApi) { Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateWebUiProps.toString) @@ -74,7 +65,7 @@ class WebUiPropsTest extends V510ServerSetup { response510.code should equal(201) val customerJson = response510.body.extract[WebUiPropsCommons] - val requestGet510 = (v5_1_0_Request / "webui-props").GET <@(user1) + val requestGet510 = (v5_1_0_Request / "webui-props").GET val responseGet510 = makeGetRequest(requestGet510) Then("We should get a 200") responseGet510.code should equal(200)