Merge pull request #2619 from constantine2nd/develop

Duplicate consumer creation on consent creation
This commit is contained in:
Simon Redfern 2025-09-26 13:14:27 +02:00 committed by GitHub
commit 4a5e652cd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 834 additions and 118 deletions

View File

@ -159,6 +159,18 @@ case class APIFailureNewStyle(failMsg: String,
}
}
object ObpApiFailure {
def apply(failMsg: String, failCode: Int = 400, cc: Option[CallContext] = None) = {
fullBoxOrException(Empty ~> APIFailureNewStyle(failMsg, failCode, cc.map(_.toLight)))
}
// overload for plain CallContext
def apply(failMsg: String, failCode: Int, cc: CallContext) = {
fullBoxOrException(Empty ~> APIFailureNewStyle(failMsg, failCode, Some(cc.toLight)))
}
}
//if you change this, think about backwards compatibility! All existing
//versions of the API return this failure message, so if you change it, make sure
//that all stable versions retain the same behavior

View File

@ -3947,6 +3947,24 @@ object SwaggerDefinitionsJSON {
Some(redisCallLimitJson)
)
lazy val callLimitsJson510Example: CallLimitsJson510 = CallLimitsJson510(
limits = List(
CallLimitJson510(
rate_limiting_id = "80e1e0b2-d8bf-4f85-a579-e69ef36e3305",
from_date = DateWithDayExampleObject,
to_date = DateWithDayExampleObject,
per_second_call_limit = "100",
per_minute_call_limit = "100",
per_hour_call_limit = "-1",
per_day_call_limit = "-1",
per_week_call_limit = "-1",
per_month_call_limit = "-1",
created_at = DateWithDayExampleObject,
updated_at = DateWithDayExampleObject
)
)
)
lazy val accountWebhookPostJson = AccountWebhookPostJson(
account_id =accountIdExample.value,
trigger_name = ApiTrigger.onBalanceChange.toString(),

View File

@ -4021,7 +4021,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
} yield {
tpps match {
case Nil =>
Failure(RegulatedEntityNotFoundByCertificate)
ObpApiFailure(RegulatedEntityNotFoundByCertificate, 401, cc)
case single :: Nil =>
logger.debug(s"Regulated entity by certificate: $single")
// Only one match, proceed to role check
@ -4029,12 +4029,12 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
logger.debug(s"Regulated entity by certificate (single.services: ${single.services}, serviceProvider: $serviceProvider): ")
Full(true)
} else {
Failure(X509ActionIsNotAllowed)
ObpApiFailure(X509ActionIsNotAllowed, 403, cc)
}
case multiple =>
// Ambiguity detected: more than one TPP matches the certificate
val names = multiple.map(e => s"'${e.entityName}' (Code: ${e.entityCode})").mkString(", ")
Failure(s"$RegulatedEntityAmbiguityByCertificate: multiple TPPs found: $names")
ObpApiFailure(s"$RegulatedEntityAmbiguityByCertificate: multiple TPPs found: $names", 401, cc)
}
}
case value if value.toUpperCase == "CERTIFICATE" => Future {

View File

@ -73,11 +73,14 @@ object BerlinGroupError {
case "401" if message.contains("OBP-35005") => "CONSENT_INVALID"
case "401" if message.contains("OBP-20300") => "CERTIFICATE_BLOCKED"
case "401" if message.contains("OBP-34102") => "CERTIFICATE_BLOCKED"
case "401" if message.contains("OBP-34103") => "CERTIFICATE_BLOCKED"
case "401" if message.contains("OBP-20312") => "CERTIFICATE_INVALID"
case "401" if message.contains("OBP-20300") => "CERTIFICATE_INVALID"
case "401" if message.contains("OBP-20310") => "SIGNATURE_INVALID"
case "401" if message.contains("OBP-20060") => "ROLE_INVALID"
case "403" if message.contains("OBP-20307") => "ROLE_INVALID"
case "403" if message.contains("OBP-20060") => "ROLE_INVALID"
case "400" if message.contains("OBP-10034") => "PARAMETER_NOT_CONSISTENT"

View File

@ -1,15 +1,15 @@
package code.api.util
import code.api.{APIFailureNewStyle, RequestHeader}
import code.api.util.APIUtil.{OBPReturnType, fullBoxOrException}
import code.api.util.APIUtil.OBPReturnType
import code.api.util.ErrorUtil.apiFailure
import code.api.util.newstyle.RegulatedEntityNewStyle.getRegulatedEntitiesNewStyle
import code.api.{ObpApiFailure, RequestHeader}
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.{RegulatedEntityAttributeSimple, RegulatedEntityTrait, User}
import net.liftweb.common.{Box, Empty, Failure, Full}
import com.openbankproject.commons.model.{RegulatedEntityTrait, User}
import net.liftweb.common.{Box, Failure, Full}
import net.liftweb.http.provider.HTTPParam
import net.liftweb.util.Helpers
@ -277,66 +277,64 @@ object BerlinGroupSigning extends MdcLoggable {
val tppSignatureCert: String = APIUtil.getRequestHeader(RequestHeader.`TPP-Signature-Certificate`, requestHeaders)
if (tppSignatureCert.isEmpty) {
Future(forwardResult)
} else { // Dynamic consumer creation/update works in case that RequestHeader.`TPP-Signature-Certificate is present in the current call
} else { // Dynamic consumer creation/update works in case that RequestHeader.`TPP-Signature-Certificate` is present
val certificate = getCertificateFromTppSignatureCertificate(requestHeaders)
// Use the regular expression to find the value of EMAILADDRESS
val extractedEmail = emailPattern.findFirstMatchIn(certificate.getSubjectDN.getName) match {
case Some(m) => Some(m.group(1)) // Extract the value of EMAILADDRESS
case None => None
}
// Use the regular expression to find the value of Organisation
val extractOrganisation = organisationlPattern.findFirstMatchIn(certificate.getSubjectDN.getName) match {
case Some(m) => Some(m.group(1)) // Extract the value of Organisation
case None => None
}
val extractedEmail = emailPattern.findFirstMatchIn(certificate.getSubjectDN.getName).map(_.group(1))
val extractOrganisation = organisationlPattern.findFirstMatchIn(certificate.getSubjectDN.getName).map(_.group(1))
for {
entities <- getRegulatedEntityByCertificate(certificate, forwardResult._2) // Find Regulated Entity via certificate
entities <- getRegulatedEntityByCertificate(certificate, forwardResult._2)
} yield {
// Certificate can be changed but this value is permanent per Regulated entity
val idno = entities.map(_.entityCode).headOption.getOrElse("")
entities match {
case Nil =>
(ObpApiFailure(ErrorMessages.RegulatedEntityNotFoundByCertificate, 401, forwardResult._2), forwardResult._2)
val entityName = entities.map(_.entityName).headOption
case single :: Nil =>
val idno = single.entityCode
val entityName = Option(single.entityName)
// Get or create consumer by the unique key (azp, iss)
val consumer: Box[Consumer] = Consumers.consumers.vend.getOrCreateConsumer(
consumerId = None,
key = Some(Helpers.randomString(40).toLowerCase),
secret = Some(Helpers.randomString(40).toLowerCase),
aud = None,
azp = Some(idno), // The pair (azp, iss) is a unique key in case of Client of an Identity Provider
iss = Some(RequestHeader.`TPP-Signature-Certificate`),
sub = None,
Some(true),
name = entityName,
appType = None,
description = Some(s"Certificate serial number:${certificate.getSerialNumber}"),
developerEmail = extractedEmail,
redirectURL = None,
createdByUserId = None,
certificate = None,
logoUrl = code.api.Constant.consumerDefaultLogoUrl
)
// Set or update certificate
consumer match {
case Full(consumer) =>
val certificateFromHeader = getHeaderValue(RequestHeader.`TPP-Signature-Certificate`, requestHeaders)
Consumers.consumers.vend.updateConsumer(
id = consumer.id.get,
val consumer: Box[Consumer] = Consumers.consumers.vend.getOrCreateConsumer(
consumerId = None,
key = Some(Helpers.randomString(40).toLowerCase),
secret = Some(Helpers.randomString(40).toLowerCase),
aud = None,
azp = Some(idno),
iss = Some(RequestHeader.`TPP-Signature-Certificate`),
sub = None,
Some(true),
name = entityName,
certificate = Some(certificateFromHeader)
) match {
appType = None,
description = Some(s"Certificate serial number:${certificate.getSerialNumber}"),
developerEmail = extractedEmail,
redirectURL = None,
createdByUserId = None,
certificate = None,
logoUrl = code.api.Constant.consumerDefaultLogoUrl
)
consumer match {
case Full(consumer) =>
// Update call context with a created consumer
(forwardResult._1, forwardResult._2.map(_.copy(consumer = Full(consumer))))
val certificateFromHeader = getHeaderValue(RequestHeader.`TPP-Signature-Certificate`, requestHeaders)
Consumers.consumers.vend.updateConsumer(
id = consumer.id.get,
name = entityName,
certificate = Some(certificateFromHeader)
) match {
case Full(updatedConsumer) =>
(forwardResult._1, forwardResult._2.map(_.copy(consumer = Full(updatedConsumer))))
case error =>
logger.debug(error)
(Failure(s"${ErrorMessages.CreateConsumerError} Regulated entity: $idno"), forwardResult._2)
}
case error =>
logger.debug(error)
(Failure(s"${ErrorMessages.CreateConsumerError} Regulated entity: $idno"), forwardResult._2)
}
case error =>
logger.debug(error)
(Failure(s"${ErrorMessages.CreateConsumerError} Regulated entity: $idno"), forwardResult._2)
case multiple =>
val names = multiple.map(e => s"'${e.entityName}' (Code: ${e.entityCode})").mkString(", ")
(ObpApiFailure(s"${ErrorMessages.RegulatedEntityAmbiguityByCertificate}: multiple TPPs found: $names", 401, forwardResult._2), forwardResult._2)
}
}
}

View File

@ -3304,7 +3304,7 @@ trait APIMethods510 {
|
|""".stripMargin,
EmptyBody,
callLimitJson,
callLimitsJson510Example,
List(
$UserNotLoggedIn,
InvalidJsonFormat,
@ -3319,19 +3319,15 @@ trait APIMethods510 {
lazy val getCallsLimit: OBPEndpoint = {
case "management" :: "consumers" :: consumerId :: "consumer" :: "call-limits" :: Nil JsonGet _ => {
case "management" :: "consumers" :: consumerId :: "consumer" :: "call-limits" :: Nil JsonGet _ =>
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
// (Full(u), callContext) <- authenticatedAccess(cc)
// _ <- NewStyle.function.hasEntitlement("", cc.userId, canReadCallLimits, callContext)
consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, cc.callContext)
rateLimiting: Option[RateLimiting] <- RateLimitingDI.rateLimiting.vend.findMostRecentRateLimit(consumerId, None, None, None)
rateLimit <- Future(RateLimitingUtil.consumerRateLimitState(consumer.consumerId.get).toList)
_ <- NewStyle.function.getConsumerByConsumerId(consumerId, cc.callContext)
rateLimiting <- RateLimitingDI.rateLimiting.vend.getAllByConsumerId(consumerId, None)
} yield {
(createCallLimitJson(consumer, rateLimiting, rateLimit), HttpCode.`200`(cc.callContext))
(createCallLimitJson(rateLimiting), HttpCode.`200`(cc.callContext))
}
}
}

View File

@ -686,6 +686,7 @@ case class ViewPermissionJson(
)
case class CallLimitJson510(
rate_limiting_id: String,
from_date: Date,
to_date: Date,
per_second_call_limit : String,
@ -695,9 +696,9 @@ case class CallLimitJson510(
per_week_call_limit : String,
per_month_call_limit : String,
created_at : Date,
updated_at : Date,
current_state: Option[RedisCallLimitJson]
updated_at : Date
)
case class CallLimitsJson510(limits: List[CallLimitJson510])
object JSONFactory510 extends CustomJsonFormats with MdcLoggable {
@ -1323,48 +1324,26 @@ object JSONFactory510 extends CustomJsonFormats with MdcLoggable {
)
}
def createCallLimitJson(consumer: Consumer, rateLimiting: Option[RateLimiting], rateLimits: List[((Option[Long], Option[Long]), LimitCallPeriod)]): CallLimitJson510 = {
val redisRateLimit = rateLimits match {
case Nil => None
case _ =>
def getInfo(period: RateLimitingPeriod.Value): Option[RateLimit] = {
rateLimits.filter(_._2 == period) match {
case x :: Nil =>
x._1 match {
case (Some(x), Some(y)) => Some(RateLimit(Some(x), Some(y)))
case _ => None
}
case _ => None
}
}
Some(
RedisCallLimitJson(
getInfo(RateLimitingPeriod.PER_SECOND),
getInfo(RateLimitingPeriod.PER_MINUTE),
getInfo(RateLimitingPeriod.PER_HOUR),
getInfo(RateLimitingPeriod.PER_DAY),
getInfo(RateLimitingPeriod.PER_WEEK),
getInfo(RateLimitingPeriod.PER_MONTH)
)
def createCallLimitJson(rateLimitings: List[RateLimiting]): CallLimitsJson510 = {
CallLimitsJson510(
rateLimitings.map( i =>
CallLimitJson510(
rate_limiting_id = i.rateLimitingId,
from_date = i.fromDate,
to_date = i.toDate,
per_second_call_limit = i.perSecondCallLimit.toString,
per_minute_call_limit = i.perMinuteCallLimit.toString,
per_hour_call_limit = i.perHourCallLimit.toString,
per_day_call_limit = i.perDayCallLimit.toString,
per_week_call_limit = i.perWeekCallLimit.toString,
per_month_call_limit = i.perMonthCallLimit.toString,
created_at = i.createdAt.get,
updated_at = i.updatedAt.get,
)
}
CallLimitJson510(
from_date = rateLimiting.map(_.fromDate).orNull,
to_date = rateLimiting.map(_.toDate).orNull,
per_second_call_limit = rateLimiting.map(_.perSecondCallLimit.toString).getOrElse("-1"),
per_minute_call_limit = rateLimiting.map(_.perMinuteCallLimit.toString).getOrElse("-1"),
per_hour_call_limit = rateLimiting.map(_.perHourCallLimit.toString).getOrElse("-1"),
per_day_call_limit = rateLimiting.map(_.perDayCallLimit.toString).getOrElse("-1"),
per_week_call_limit = rateLimiting.map(_.perWeekCallLimit.toString).getOrElse("-1"),
per_month_call_limit = rateLimiting.map(_.perMonthCallLimit.toString).getOrElse("-1"),
created_at = rateLimiting.map(_.createdAt.get).orNull,
updated_at = rateLimiting.map(_.updatedAt.get).orNull,
redisRateLimit
)
)
}
}

View File

@ -2,11 +2,13 @@ package code.api.v6_0_0
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._
import code.api.util.APIUtil._
import code.api.util.ApiRole.canReadCallLimits
import code.api.util.ApiTag._
import code.api.util.ErrorMessages.{$UserNotLoggedIn, InvalidJsonFormat, UnknownError, _}
import code.api.util.FutureUtil.EndpointContext
import code.api.util.NewStyle
import code.api.util.{NewStyle, RateLimitingUtil}
import code.api.util.NewStyle.HttpCode
import code.api.v6_0_0.JSONFactory600.createCurrentUsageJson
import code.bankconnectors.LocalMappedConnectorInternal
import code.bankconnectors.LocalMappedConnectorInternal._
import code.entitlement.Entitlement
@ -20,6 +22,7 @@ import com.openbankproject.commons.ExecutionContext.Implicits.global
import scala.collection.immutable.{List, Nil}
import scala.collection.mutable.ArrayBuffer
import scala.concurrent.Future
trait APIMethods600 {
@ -38,6 +41,46 @@ trait APIMethods600 {
val codeContext = CodeContext(staticResourceDocs, apiRelations)
staticResourceDocs += ResourceDoc(
getCurrentCallsLimit,
implementedInApiVersion,
nameOf(getCurrentCallsLimit),
"GET",
"/management/consumers/CONSUMER_ID/consumer/current-usage",
"Get Call Limits for a Consumer Usage",
s"""
|Get Call Limits for a Consumer Usage.
|${userAuthenticationMessage(true)}
|
|""".stripMargin,
EmptyBody,
redisCallLimitJson,
List(
$UserNotLoggedIn,
InvalidJsonFormat,
InvalidConsumerId,
ConsumerNotFoundByConsumerId,
UserHasMissingRoles,
UpdateConsumerError,
UnknownError
),
List(apiTagConsumer),
Some(List(canReadCallLimits)))
lazy val getCurrentCallsLimit: OBPEndpoint = {
case "management" :: "consumers" :: consumerId :: "consumer" :: "current-usage" :: Nil JsonGet _ =>
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
_ <- NewStyle.function.getConsumerByConsumerId(consumerId, cc.callContext)
currentUsage <- Future(RateLimitingUtil.consumerRateLimitState(consumerId).toList)
} yield {
(createCurrentUsageJson(currentUsage), HttpCode.`200`(cc.callContext))
}
}
staticResourceDocs += ResourceDoc(
getCurrentUser,
implementedInApiVersion,

View File

@ -27,9 +27,11 @@
package code.api.v6_0_0
import code.api.util.APIUtil.stringOrNull
import code.api.util.RateLimitingPeriod.LimitCallPeriod
import code.api.util._
import code.api.v2_0_0.{EntitlementJSONs, JSONFactory200}
import code.api.v3_0_0.{UserJsonV300, ViewJSON300, ViewsJSON300}
import code.api.v3_1_0.{RateLimit, RedisCallLimitJson}
import code.entitlement.Entitlement
import code.util.Helper.MdcLoggable
import com.openbankproject.commons.model._
@ -78,6 +80,33 @@ case class UserV600(user: User, entitlements: List[Entitlement], views: Option[P
case class UsersJsonV600(current_user: UserV600, on_behalf_of_user: UserV600)
object JSONFactory600 extends CustomJsonFormats with MdcLoggable{
def createCurrentUsageJson(rateLimits: List[((Option[Long], Option[Long]), LimitCallPeriod)]): Option[RedisCallLimitJson] = {
if (rateLimits.isEmpty) None
else {
val grouped: Map[LimitCallPeriod, (Option[Long], Option[Long])] =
rateLimits.map { case (limits, period) => period -> limits }.toMap
def getInfo(period: RateLimitingPeriod.Value): Option[RateLimit] =
grouped.get(period).collect {
case (Some(x), Some(y)) => RateLimit(Some(x), Some(y))
}
Some(
RedisCallLimitJson(
getInfo(RateLimitingPeriod.PER_SECOND),
getInfo(RateLimitingPeriod.PER_MINUTE),
getInfo(RateLimitingPeriod.PER_HOUR),
getInfo(RateLimitingPeriod.PER_DAY),
getInfo(RateLimitingPeriod.PER_WEEK),
getInfo(RateLimitingPeriod.PER_MONTH)
)
)
}
}
def createUserInfoJSON(current_user: UserV600, onBehalfOfUser: Option[UserV600]): UserJsonV600 = {
UserJsonV600(
user_id = current_user.user.userId,

View File

@ -44,7 +44,9 @@ object GetHtmlFromUrl extends MdcLoggable {
def vendorSupportHtml = tryo(scala.io.Source.fromURL(vendorSupportHtmlUrl))
logger.debug("vendorSupportHtml: " + vendorSupportHtml)
def vendorSupportHtmlScript = vendorSupportHtml.map(_.mkString).getOrElse("")
def vendorSupportHtmlScript = vendorSupportHtml.map { source =>
try source.mkString finally source.close()
}.getOrElse("")
logger.debug("vendorSupportHtmlScript: " + vendorSupportHtmlScript)
val jsVendorSupportHtml: NodeSeq = vendorSupportHtmlScript match {
case "" => <script></script>

View File

@ -174,7 +174,8 @@ class WebUI extends MdcLoggable{
val sdksExternalHtmlLink = getWebUiPropsValue("webui_featured_sdks_external_link","")
val sdksExternalHtmlContent = try {
Source.fromURL(sdksExternalHtmlLink, "UTF-8").mkString
val source = Source.fromURL(sdksExternalHtmlLink, "UTF-8")
try source.mkString finally source.close()
} catch {
case _ : Throwable => "<h1>SDK Showcases is wrong, please check the props `webui_featured_sdks_external_link` </h1>"
}
@ -199,7 +200,8 @@ class WebUI extends MdcLoggable{
val mainFaqHtmlLink = getWebUiPropsValue("webui_main_faq_external_link","")
val mainFaqExternalHtmlContent = try {
Source.fromURL(mainFaqHtmlLink, "UTF-8").mkString
val source = Source.fromURL(mainFaqHtmlLink, "UTF-8")
try source.mkString finally source.close()
} catch {
case _ : Throwable => "<h1>FAQs is wrong, please check the props `webui_main_faq_external_link` </h1>"
}
@ -618,7 +620,9 @@ class WebUI extends MdcLoggable{
logger.info("htmlTry: " + htmlTry)
// Convert to a string
val htmlString = htmlTry.map(_.mkString).getOrElse("")
val htmlString = htmlTry.map { source =>
try source.mkString finally source.close()
}.getOrElse("")
logger.info("htmlString: " + htmlString)
// Create an HTML object

View File

@ -0,0 +1,161 @@
package code.api.berlin.group.signing
import java.nio.charset.StandardCharsets
import java.security.spec.PKCS8EncodedKeySpec
import java.security.{KeyFactory, MessageDigest, PrivateKey, Signature}
import java.time.format.DateTimeFormatter
import java.time.{ZoneOffset, ZonedDateTime}
import java.util.{Base64, UUID}
import scala.util.{Failure, Success, Try}
/**
* PSD2 Request Signer for Berlin Group API calls
*
* This utility provides cryptographic signing for Berlin Group PSD2 API requests.
* It follows the HTTP signature standard required by PSD2 regulations.
*
* Usage:
* val signer = new PSD2RequestSigner(privateKeyPem, certificatePem)
* val headers = signer.signRequest(requestBody)
*/
class PSD2RequestSigner(
privateKeyPem: String,
certificatePem: String,
keyId: String = "SN=1082, CA=CN=MAIB Prisacaru Sergiu (Test), O=MAIB"
) {
// Parse private key once during initialization
private val privateKey: PrivateKey = parsePrivateKey(privateKeyPem) match {
case Success(key) => key
case Failure(ex) => throw new IllegalArgumentException(s"Invalid private key: ${ex.getMessage}", ex)
}
// Encode certificate once during initialization
private val certificateBase64: String = Base64.getEncoder.encodeToString(
certificatePem.getBytes(StandardCharsets.UTF_8)
)
/**
* Sign a Berlin Group API request and return headers
*
* @param requestBody The JSON request body as string
* @param psuDeviceId Optional PSU device ID (default: "device-1234567890")
* @param psuDeviceName Optional PSU device name (default: "Kalina-PC")
* @param psuIpAddress Optional PSU IP address (default: "psu-service.local")
* @param tppRedirectUri Optional TPP redirect URI (default: "tppapp://example.com/redirect")
* @param tppNokRedirectUri Optional TPP error redirect URI (default: "https://example.com/redirect")
* @return Map of HTTP headers for the signed request
*/
def signRequest(
requestBody: String,
psuDeviceId: String = "device-1234567890",
psuDeviceName: String = "Kalina-PC",
psuIpAddress: String = "psu-service.local", // Use DNS/hostname instead of raw IP
tppRedirectUri: String = "tppapp://example.com/redirect",
tppNokRedirectUri: String = "https://example.com/redirect"
): Map[String, String] = {
// Generate required header values
val xRequestId = UUID.randomUUID().toString
val dateHeader = ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.RFC_1123_DATE_TIME)
val digestHeader = createDigestHeader(requestBody)
// Create signature string according to PSD2 specification
val dataToSign = s"digest: $digestHeader\ndate: $dateHeader\nx-request-id: $xRequestId"
val signature = signData(dataToSign)
// Create signature header
val signatureHeader = s"""keyId="$keyId", algorithm="rsa-sha256", headers="digest date x-request-id", signature="$signature""""
// Return complete headers map
Map(
"Content-Type" -> "application/json",
"Date" -> dateHeader,
"X-Request-ID" -> xRequestId,
"Digest" -> digestHeader,
"Signature" -> signatureHeader,
"TPP-Signature-Certificate" -> certificateBase64,
"PSU-Device-ID" -> psuDeviceId,
"PSU-Device-Name" -> psuDeviceName,
"PSU-IP-Address" -> psuIpAddress,
"TPP-Redirect-URI" -> tppRedirectUri,
"TPP-Nok-Redirect-URI" -> tppNokRedirectUri
)
}
/**
* Create SHA-256 digest header for request body
*/
private def createDigestHeader(requestBody: String): String = {
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(requestBody.getBytes(StandardCharsets.UTF_8))
val base64Hash = Base64.getEncoder.encodeToString(hashBytes)
s"SHA-256=$base64Hash"
}
/**
* Sign data using RSA-SHA256 algorithm
*/
private def signData(dataToSign: String): String = {
val signature = Signature.getInstance("SHA256withRSA")
signature.initSign(privateKey)
signature.update(dataToSign.getBytes(StandardCharsets.UTF_8))
val signatureBytes = signature.sign()
Base64.getEncoder.encodeToString(signatureBytes)
}
/**
* Parse PEM-formatted private key
*/
private def parsePrivateKey(privateKeyPem: String): Try[PrivateKey] = Try {
val cleanedPem = privateKeyPem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace("-----BEGIN RSA PRIVATE KEY-----", "")
.replace("-----END RSA PRIVATE KEY-----", "")
.replaceAll("\\s", "")
val keyBytes = Base64.getDecoder.decode(cleanedPem)
val keySpec = new PKCS8EncodedKeySpec(keyBytes)
val keyFactory = KeyFactory.getInstance("RSA")
keyFactory.generatePrivate(keySpec)
}
}
/**
* Simple trait for mixing into test classes to provide PSD2 signing capabilities
*/
trait PSD2SigningSupport {
/**
* Override these in your test class to provide actual certificate content
*/
def berlinGroupPrivateKey: String = throw new NotImplementedError("berlinGroupPrivateKey must be implemented")
def berlinGroupCertificate: String = throw new NotImplementedError("berlinGroupCertificate must be implemented")
def berlinGroupKeyId: String = "SN=1082, CA=CN=MAIB Prisacaru Sergiu (Test), O=MAIB"
private lazy val psd2Signer = new PSD2RequestSigner(berlinGroupPrivateKey, berlinGroupCertificate, berlinGroupKeyId)
/**
* Sign a Berlin Group request and return headers
*/
def signPSD2Request(requestBody: String): Map[String, String] = {
psd2Signer.signRequest(requestBody)
}
/**
* Sign a Berlin Group request with custom PSU parameters
*/
def signPSD2Request(
requestBody: String,
psuDeviceId: String,
psuDeviceName: String,
psuIpAddress: String,
tppRedirectUri: String = "tppapp://example.com/redirect",
tppNokRedirectUri: String = "https://example.com/redirect"
): Map[String, String] = {
psd2Signer.signRequest(requestBody, psuDeviceId, psuDeviceName, psuIpAddress, tppRedirectUri, tppNokRedirectUri)
}
}

View File

@ -0,0 +1,136 @@
package code.api.berlin.group.signing
import code.api.util.APIUtil
import net.liftweb.common.Box
import net.liftweb.util.Props
import org.scalatest.{BeforeAndAfterEach, Suite}
import java.nio.file.Path
import scala.util.{Failure, Success}
/**
* Test support trait that automatically generates and configures PSD2 certificates on the fly
* This eliminates the need for external certificate files in tests
*/
trait PSD2SigningTestSupport extends BeforeAndAfterEach with PSD2SigningSupport { self: Suite =>
// Generated certificate data
private var _certificateData: Option[TestCertificateGenerator.CertificateData] = None
private var _p12Path: Option[Path] = None
// Test configuration
protected def tppSignaturePassword: String = "testpassword123"
protected def tppSignatureAlias: String = "test-tpp-alias"
protected def tppCommonName: String = "Berlin Group Test TPP"
protected def tppOrganization: String = "Test Bank Organization"
override def beforeEach(): Unit = {
super.beforeEach()
// Generate certificates on the fly
TestCertificateGenerator.generateTestCertificateWithTempFiles(
commonName = tppCommonName,
organizationName = tppOrganization,
password = tppSignaturePassword,
alias = tppSignatureAlias
) match {
case Success((certData, tempP12Path)) =>
_certificateData = Some(certData)
_p12Path = Some(tempP12Path)
// Set up properties for the test
setPropsValues(
"truststore.path.tpp_signature" -> tempP12Path.toString,
"truststore.password.tpp_signature" -> tppSignaturePassword,
"truststore.alias.tpp_signature" -> tppSignatureAlias,
"use_tpp_signature_revocation_list" -> "false"
)
println(s"Generated test certificate for: $tppCommonName")
println(s"Created temporary P12 keystore at: $tempP12Path")
case Failure(exception) =>
throw new RuntimeException(s"Failed to generate test certificates: ${exception.getMessage}", exception)
}
}
override def afterEach(): Unit = {
// Clean up temporary files
_p12Path.foreach { path =>
try {
java.nio.file.Files.deleteIfExists(path)
} catch {
case _: Exception => // Ignore cleanup errors
}
}
_p12Path = None
_certificateData = None
super.afterEach()
}
// Implementation of PSD2SigningSupport
override def berlinGroupPrivateKey: String = {
_certificateData
.map(_.privateKeyPem)
.getOrElse(throw new IllegalStateException("Certificate data not initialized. Make sure beforeEach() is called."))
}
override def berlinGroupCertificate: String = {
_certificateData
.map(_.certificatePem)
.getOrElse(throw new IllegalStateException("Certificate data not initialized. Make sure beforeEach() is called."))
}
override def berlinGroupKeyId: String = {
_certificateData match {
case Some(certData) =>
val serialNumber = certData.serialNumber.toString
s"SN=$serialNumber, CA=CN=$tppCommonName, O=$tppOrganization"
case None =>
throw new IllegalStateException("Certificate data not initialized. Make sure beforeEach() is called.")
}
}
/**
* This method should be provided by the parent test class that extends PropsReset
* We assume it's available from the test setup
*/
protected def setPropsValues(keyValuePairs: (String, String)*): Unit
/**
* Get the generated certificate data for advanced test scenarios
*/
protected def getCertificateData: Option[TestCertificateGenerator.CertificateData] = _certificateData
/**
* Get the temporary P12 file path
*/
protected def getP12Path: Option[Path] = _p12Path
/**
* Validate that all necessary properties are set
*/
protected def validateTestSetup(): Unit = {
val requiredProps = List(
"truststore.path.tpp_signature",
"truststore.password.tpp_signature",
"truststore.alias.tpp_signature"
)
requiredProps.foreach { prop =>
val value = APIUtil.getPropsValue(prop).getOrElse("")
if (value.isEmpty) {
throw new IllegalStateException(s"Required property '$prop' is not set")
}
}
// Verify certificate data is available
if (_certificateData.isEmpty) {
throw new IllegalStateException("Certificate data is not initialized")
}
println("Test setup validation passed")
}
}

View File

@ -0,0 +1,113 @@
package code.api.berlin.group.signing
import code.api.berlin.group.v1_3.BerlinGroupServerSetupV1_3
import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.ErrorMessagesBG
class RegulatedEntityTest extends BerlinGroupServerSetupV1_3 with PSD2SigningTestSupport {
override def beforeEach(): Unit = {
super.beforeEach()
// Additional test-specific properties
setPropsValues(
"use_consumer_limits" -> "false",
"allow_anonymous_access" -> "true",
"berlin_group_psd2_signing_enabled" -> "true"
)
// Validate that everything is set up correctly
validateTestSetup()
}
// Override certificate details for this specific test
override protected def tppCommonName: String = "Berlin Group Test TPP Certificate"
override protected def tppOrganization: String = "Some Test Bank"
override protected def tppSignaturePassword: String = "testpassword123"
override protected def tppSignatureAlias: String = "bnm test"
scenario("Create signed consent request with dynamically generated certificates") {
Given("A consent request body")
val requestBody = """{
"access": {
"accounts": [],
"balances": [],
"transactions": []
},
"recurringIndicator": true,
"validUntil": "2024-12-31",
"frequencyPerDay": 4
}"""
When("I sign the request using the generated certificates")
val headers = signPSD2Request(requestBody)
Then("The headers should contain the required PSD2 signing elements")
headers should contain key "X-Request-ID"
headers should contain key "Digest"
headers should contain key "TPP-Signature-Certificate"
headers should contain key "Signature"
And("I can use the signed request with OBP's HTTP client")
val request = (V1_3_BG / "consents").POST
val response = makePostRequestAdditionalHeader(request, requestBody, headers.toList)
// Since this is a test certificate, we expect authentication to fail but with proper structure
response.code should equal(401)
response.body.extract[ErrorMessagesBG].tppMessages.head.code should equal("CERTIFICATE_BLOCKED")
}
scenario("Test certificate validation and signing process") {
Given("A payment initiation request body")
val paymentRequestBody = """{
"instructedAmount": {
"currency": "EUR",
"amount": "123.45"
},
"debtorAccount": {
"iban": "DE02100100109307118603"
},
"creditorName": "John Doe",
"creditorAccount": {
"iban": "DE23100120020123456789"
},
"remittanceInformationUnstructured": "Test payment"
}"""
When("I create a signature for the payment request")
val signedHeaders = signPSD2Request(paymentRequestBody)
Then("The signature should be valid and contain all required headers")
signedHeaders should have size (11)
signedHeaders("Digest") should startWith("SHA-256=")
signedHeaders("Signature") should include("keyId=")
signedHeaders("X-Request-ID") should not be empty
And("The request should be properly formatted for Berlin Group API")
val request = (V1_3_BG / "payments" / "sepa-credit-transfers").POST
val response = makePostRequestAdditionalHeader(request, paymentRequestBody, signedHeaders.toList)
// We expect authentication failure with test certificates, but the structure should be valid
response.code should (equal(401) or equal(400) or equal(403))
}
scenario("Test custom certificate parameters") {
Given("Custom certificate parameters")
val customCertData = TestCertificateGenerator.generateTestCertificate(
commonName = "Custom Test Certificate",
organizationName = "Custom Test Org",
validityDays = 30
)
customCertData should be a 'success
When("I inspect the generated certificate")
val certData = customCertData.get
Then("It should have the correct properties")
certData.certificate.getSubjectDN.getName should include("Custom Test Certificate")
certData.certificate.getSubjectDN.getName should include("Custom Test Org")
And("The certificate should be valid")
certData.certificate.checkValidity() // Should not throw exception
}
}

View File

@ -0,0 +1,207 @@
package code.api.berlin.group.signing
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x509.{BasicConstraints, Extension, KeyUsage, SubjectPublicKeyInfo}
import org.bouncycastle.cert.jcajce.{JcaX509CertificateConverter, JcaX509v3CertificateBuilder}
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.openssl.jcajce.JcaPEMWriter
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
import java.io.{ByteArrayOutputStream, StringWriter}
import java.math.BigInteger
import java.nio.file.{Files, Path}
import java.security._
import java.security.cert.X509Certificate
import java.time.{LocalDateTime, ZoneOffset}
import java.util.Date
import scala.util.Try
/**
* Utility class for generating test certificates and keystores on the fly
* Used for Berlin Group PSD2 testing without relying on external certificate files
*/
object TestCertificateGenerator {
// Add BouncyCastle provider
Security.addProvider(new BouncyCastleProvider())
case class CertificateData(
privateKey: PrivateKey,
publicKey: PublicKey,
certificate: X509Certificate,
privateKeyPem: String,
certificatePem: String,
p12Data: Array[Byte],
serialNumber: BigInteger
)
/**
* Generate a self-signed test certificate with private key
*/
def generateTestCertificate(
commonName: String = "Test TPP Certificate",
organizationName: String = "Test Organization",
keySize: Int = 2048,
validityDays: Int = 365,
password: String = "password",
alias: String = "test-alias"
): Try[CertificateData] = {
Try {
// Generate key pair
val keyPairGenerator = KeyPairGenerator.getInstance("RSA")
keyPairGenerator.initialize(keySize)
val keyPair = keyPairGenerator.generateKeyPair()
val privateKey = keyPair.getPrivate
val publicKey = keyPair.getPublic
// Create certificate
val now = new Date()
val notBefore = now
val notAfter = Date.from(LocalDateTime.now().plusDays(validityDays).toInstant(ZoneOffset.UTC))
val dnName = new X500Name(s"CN=$commonName, O=$organizationName, C=US")
val certSerialNumber = BigInteger.valueOf(System.currentTimeMillis())
val subjectPublicKeyInfo = SubjectPublicKeyInfo.getInstance(publicKey.getEncoded)
val certBuilder = new JcaX509v3CertificateBuilder(
dnName, // issuer
certSerialNumber,
notBefore,
notAfter,
dnName, // subject (same as issuer for self-signed)
publicKey
)
// Add extensions
certBuilder.addExtension(Extension.basicConstraints, false, new BasicConstraints(false))
certBuilder.addExtension(Extension.keyUsage, false,
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment | KeyUsage.nonRepudiation))
// Sign the certificate
val contentSigner = new JcaContentSignerBuilder("SHA256WithRSA").build(privateKey)
val certHolder = certBuilder.build(contentSigner)
val certificateConverter = new JcaX509CertificateConverter()
val certificate = certificateConverter.getCertificate(certHolder)
// Convert to PEM format
val privateKeyPem = convertPrivateKeyToPem(privateKey)
val certificatePem = convertCertificateToPem(certificate)
// Create P12 data
val p12Data = createP12KeyStore(privateKey, certificate, alias, password)
CertificateData(
privateKey = privateKey,
publicKey = publicKey,
certificate = certificate,
privateKeyPem = privateKeyPem,
certificatePem = certificatePem,
p12Data = p12Data,
serialNumber = certSerialNumber
)
}
}
/**
* Convert private key to PEM format string
*/
private def convertPrivateKeyToPem(privateKey: PrivateKey): String = {
val stringWriter = new StringWriter()
val pemWriter = new JcaPEMWriter(stringWriter)
try {
pemWriter.writeObject(privateKey)
pemWriter.flush()
stringWriter.toString
} finally {
pemWriter.close()
stringWriter.close()
}
}
/**
* Convert certificate to PEM format string
*/
private def convertCertificateToPem(certificate: X509Certificate): String = {
val stringWriter = new StringWriter()
val pemWriter = new JcaPEMWriter(stringWriter)
try {
pemWriter.writeObject(certificate)
pemWriter.flush()
stringWriter.toString
} finally {
pemWriter.close()
stringWriter.close()
}
}
/**
* Create a PKCS12 keystore with the private key and certificate
* Also adds the certificate as a trusted certificate entry for truststore validation
*/
private def createP12KeyStore(
privateKey: PrivateKey,
certificate: X509Certificate,
alias: String,
password: String
): Array[Byte] = {
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(null, null)
// Add key entry (private key + certificate chain)
val certChain = Array[java.security.cert.Certificate](certificate)
keyStore.setKeyEntry(alias, privateKey, password.toCharArray, certChain)
// Add trusted certificate entry for truststore validation
keyStore.setCertificateEntry(s"trusted-$alias", certificate)
val outputStream = new ByteArrayOutputStream()
try {
keyStore.store(outputStream, password.toCharArray)
outputStream.toByteArray
} finally {
outputStream.close()
}
}
/**
* Write P12 data to a temporary file and return the path
*/
def writeP12ToTempFile(p12Data: Array[Byte], prefix: String = "test-keystore"): Try[Path] = {
Try {
val tempFile = Files.createTempFile(prefix, ".p12")
Files.write(tempFile, p12Data)
// Mark for deletion on exit
tempFile.toFile.deleteOnExit()
tempFile
}
}
/**
* Generate a complete test certificate setup with temporary files
*/
def generateTestCertificateWithTempFiles(
commonName: String = "Test Berlin Group TPP",
organizationName: String = "Test Bank",
password: String = "testpassword123",
alias: String = "test-tpp-alias"
): Try[(CertificateData, Path)] = {
for {
certData <- generateTestCertificate(commonName, organizationName, password = password, alias = alias)
tempP12Path <- writeP12ToTempFile(certData.p12Data, "berlin-group-test")
} yield (certData, tempP12Path)
}
/**
* Default certificate data for Berlin Group tests
*/
lazy val defaultBerlinGroupTestCertificate: Try[CertificateData] = {
generateTestCertificate(
commonName = "Berlin Group Test TPP Certificate",
organizationName = "MAIB Test Bank"
)
}
}

View File

@ -124,7 +124,7 @@ class RateLimitingTest extends V510ServerSetup with PropsReset {
val response510 = makeGetRequest(request510)
Then("We should get a 200")
response510.code should equal(200)
response510.body.extract[CallLimitJson510]
response510.body.extract[CallLimitsJson510]
}

View File

@ -139,7 +139,10 @@ object PutFX extends SendServerRequests {
println(s"fxDataPath is $fxDataPath")
// This contains a list of fx rates.
val fxListData = JsonParser.parse(Source.fromFile(fxDataPath.getOrElse("ERROR")).mkString)
val fxListData = {
val source = Source.fromFile(fxDataPath.getOrElse("ERROR"))
try JsonParser.parse(source.mkString) finally source.close()
}
var fxrates = ListBuffer[FxJson]()

View File

@ -83,7 +83,10 @@ object PostCounterpartyMetadata extends SendServerRequests {
// This contains a list of counterparty lists. one list for each region
val counerpartyListData = JsonParser.parse(Source.fromFile(counterpartyDataPath).mkString)
val counerpartyListData = {
val source = Source.fromFile(counterpartyDataPath)
try JsonParser.parse(source.mkString) finally source.close()
}
var counterparties = ListBuffer[CounterpartyJSONRecord]()
// Loop through the lists
@ -122,7 +125,10 @@ object PostCounterpartyMetadata extends SendServerRequests {
val mainDataPath = "/Users/simonredfern/Documents/OpenBankProject/DATA/May_2018_ABN_Netherlands_extra/loaded_01/OBP_sandbox_pretty.json"
val mainData = JsonParser.parse(Source.fromFile(mainDataPath).mkString)
val mainData = {
val source = Source.fromFile(mainDataPath)
try JsonParser.parse(source.mkString) finally source.close()
}
val users = (mainData \ "users").children
println("got " + users.length + " users")

View File

@ -103,7 +103,10 @@ object PostCustomer extends SendServerRequests {
println(s"customerDataPath is $customerDataPath")
// This contains a list of customers.
val customerListData = JsonParser.parse(Source.fromFile(customerDataPath.getOrElse("ERROR")).mkString)
val customerListData = {
val source = Source.fromFile(customerDataPath.getOrElse("ERROR"))
try JsonParser.parse(source.mkString) finally source.close()
}
var customers = ListBuffer[CustomerFullJson]()
@ -127,7 +130,10 @@ object PostCustomer extends SendServerRequests {
println(s"mainDataPath is $mainDataPath")
val mainData = JsonParser.parse(Source.fromFile(mainDataPath.getOrElse("ERROR")).mkString)
val mainData = {
val source = Source.fromFile(mainDataPath.getOrElse("ERROR"))
try JsonParser.parse(source.mkString) finally source.close()
}
val users = (mainData \ "users").children
println("got " + users.length + " users")