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. diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index e44df81d3..3f607ecc8 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, _) 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/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/api/berlin/group/v1_3/BgSpecValidation.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala index 082c2fcd5..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 @@ -1,11 +1,13 @@ 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 import java.time.format.{DateTimeFormatter, DateTimeParseException} import java.time.{LocalDate, ZoneId} -import java.util.Date +import java.util.{Date, Locale} object BgSpecValidation { 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 ba59336ee..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 @@ -385,7 +385,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ val canReadTransactions = canReadTransactionsAccounts.map(_.accountId.value).contains(x.accountId.value) val cashAccountType = x.attributes.getOrElse(Nil).filter(_.name== "cashAccountType").map(_.value).headOption.getOrElse("") - + CoreAccountJsonV13( resourceId = x.accountId.value, iban = iBan, @@ -423,13 +423,13 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ val transactionRef = LinkHrefJson(s"/$commonPath/transactions") val canReadTransactions = canReadTransactionsAccounts.map(_.accountId.value).contains(bankAccount.accountId.value) val cashAccountType = bankAccount.attributes.getOrElse(Nil).filter(_.name== "cashAccountType").map(_.value).headOption.getOrElse("") - - + + val account = AccountJsonV13( 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 = cashAccountType, product = bankAccount.accountType, _links = AccountDetailsLinksJsonV13( @@ -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("")) @@ -495,14 +495,14 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ else Some(BgTransactionAccountJson(iban = debtorAccountIdIban)), transactionAmount = AmountOfMoneyV13( - transaction.currency.getOrElse(""), + transaction.currency.getOrElse(""), if(bgRemoveSignOfAmounts) transaction.amount.get.toString().trim.stripPrefix("-") else transaction.amount.get.toString() ), bookingDate = Some(BgSpecValidation.formatToISODate(bookingDate)) , - valueDate = Some(BgSpecValidation.formatToISODate(valueDate)), + valueDate = valueDate, remittanceInformationUnstructured = transaction.description ) } 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 a08076c4e..d42dd1594 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" @@ -1138,6 +1139,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 +1182,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 +1229,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 +1261,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 +1303,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), @@ -3026,6 +3029,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.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 Consent.applyBerlinGroupRules(APIUtil.`getConsent-ID`(reqHeaders), cc.copy(consumer = consumerByCertificate)) } else if (APIUtil.hasConsentJWT(reqHeaders)) { // Open Bank Project's Consent @@ -3395,7 +3401,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/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/BerlinGroupCheck.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala index 0a50a166a..fa165a533 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} @@ -29,6 +30,22 @@ object BerlinGroupCheck extends MdcLoggable { .map(_.trim.toLowerCase) .toList.filterNot(_.isEmpty) + 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 + + 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, @@ -46,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 (date.isDefined && !DateTimeUtil.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") @@ -101,21 +133,33 @@ 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" Algorithm: ${parsed.algorithm}") 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") + 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: ${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 ) @@ -123,11 +167,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 ) @@ -138,6 +187,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 c44914a4e..23c85214a 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" @@ -95,6 +96,8 @@ 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-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" @@ -104,6 +107,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" 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..2336f67e0 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupSignatureHeaderParser.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupSignatureHeaderParser.scala @@ -1,10 +1,12 @@ 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 ParsedSignature(keyId: ParsedKeyId, headers: List[String], signature: String) + case class ParsedKeyId(sn: String, ca: String, cn: String, o: String) + + case class ParsedSignature(keyId: ParsedKeyId, algorithm: String, headers: List[String], signature: String) def parseQuotedValue(value: String): String = value.stripPrefix("\"").stripSuffix("\"").trim @@ -18,18 +20,37 @@ 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(", ")}") } } 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 { @@ -43,24 +64,61 @@ object BerlinGroupSignatureHeaderParser { 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) + } + + /** + * 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") + } } } } 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/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index ec463c972..cfd10ed09 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 @@ -239,32 +240,37 @@ 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) { + ErrorUtil.apiFailureToBox(ErrorMessages.ConsentExpiredIssue, 401)(Some(callContext)) + } 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.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))") + 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 } @@ -588,18 +594,15 @@ 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)) - } + Future(Failure(ErrorUtil.extractFailureMessage(e)), Some(updatedCallContext)) } case failure@Failure(_, _, _) => Future(failure, Some(updatedCallContext)) @@ -733,89 +736,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/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 + } + } + } 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..af993b5b6 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,8 @@ 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 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." 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..a077a41f1 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/ErrorUtil.scala @@ -0,0 +1,46 @@ +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} +import net.liftweb.json._ + + +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[T](errorMessage: String, httpCode: Int)(cc: Option[CallContext]): Box[T] = { + val apiFailure = APIFailureNewStyle( + failMsg = errorMessage, + failCode = httpCode, + ccl = cc.map(_.toLight) + ) + val failureBox: Box[T] = Empty ~> apiFailure + fullBoxOrException(failureBox) + } + + + + implicit val formats: Formats = DefaultFormats + def extractFailureMessage(e: Throwable): String = { + parse(e.getMessage) + .extractOpt[APIFailureNewStyle] // Extract message from APIFailureNewStyle + .map(_.failMsg) // or provide a original one + .getOrElse(e.getMessage) + } + + +} 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/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/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index 135c7624f..5e0ac46e9 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.Constant._ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{ConsentAccessAccountsJson, ConsentAccessJson} @@ -44,6 +45,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._ @@ -137,6 +139,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, @@ -5146,6 +5179,68 @@ 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( + 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 { + 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() } + 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`(cc.callContext)) + } + } + } + } } 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 0463b6cf6..910851ad4 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, 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/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 072b1d50b..21f5bca8a 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] = { @@ -335,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 @@ -364,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/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 e8c2364f2..8f653f891 100644 --- a/obp-api/src/main/scala/code/model/View.scala +++ b/obp-api/src/main/scala/code/model/View.scala @@ -169,7 +169,7 @@ case class ViewExtended(val view: View) { else None val transactionFinishDate = - if (viewPermissions.exists(_ == CAN_SEE_TRANSACTION_FINISH_DATE)) Some(transaction.finishDate) + if (viewPermissions.exists(_ == CAN_SEE_TRANSACTION_FINISH_DATE)) transaction.finishDate else None val transactionBalance = 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 diff --git a/obp-api/src/main/scala/code/transaction/MappedTransaction.scala b/obp-api/src/main/scala/code/transaction/MappedTransaction.scala index 1e968ee0c..d17879a24 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/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..80606a199 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v5_1_0/WebUiPropsTest.scala @@ -0,0 +1,89 @@ +/** +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("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 + 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) + + } + } + + +} 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 883c52897..a802ceacd 100644 Binary files a/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data and b/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data differ 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