Merge remote-tracking branch 'Hongwei/develop' into feature/refactorViewPermission

# Conflicts:
#	obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala
#	obp-api/src/main/scala/code/model/View.scala
This commit is contained in:
hongwei 2025-07-12 15:12:11 +02:00
commit dc8e9e02e2
33 changed files with 666 additions and 182 deletions

View File

@ -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_<BANK_ID> to the standard props names
# e.g.

View File

@ -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)
)
}

View File

@ -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,
)

View File

@ -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 {

View File

@ -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
)
}

View File

@ -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,_,_,_) =>

View File

@ -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,

View File

@ -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)

View File

@ -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"

View File

@ -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")
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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,

View File

@ -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
}
}
}

View File

@ -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."

View File

@ -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)
}
}

View File

@ -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 =

View File

@ -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

View File

@ -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))
}
}
}
}
}

View File

@ -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,

View File

@ -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))
),

View File

@ -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))
),

View File

@ -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))
),

View File

@ -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 {

View File

@ -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
}

View File

@ -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))))),

View File

@ -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 =

View File

@ -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

View File

@ -154,7 +154,7 @@ class MappedTransaction extends LongKeyedMapper[MappedTransaction] with IdPK wit
transactionCurrency,
transactionDescription,
tStartDate.get,
tFinishDate.get,
Some(tFinishDate.get),
newBalance,
status.get))
}

View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses/>.
Email: contact@tesobe.com
TESOBE GmbH
Osloerstrasse 16/17
Berlin 13359, Germany
This product includes software developed at
TESOBE (http://www.tesobe.com/)
*/
package code.api.v5_1_0
import code.api.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)
}
}
}

View File

@ -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") {

View File

@ -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