Merge pull request #2518 from constantine2nd/develop

Berlin Group features
This commit is contained in:
Simon Redfern 2025-04-04 13:16:59 +02:00 committed by GitHub
commit aacaa36139
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 330 additions and 122 deletions

View File

@ -165,8 +165,12 @@ jwt.use.ssl=false
# Bypass TPP signature validation
# bypass_tpp_signature_validation = false
## Reject Berlin Group Consents in status "received" after defined time
# berlin_group_outdated_consents_interval = 5
## Reject Berlin Group consents with status "received" after a defined time (in seconds)
# berlin_group_outdated_consents_time_in_seconds = 300
# berlin_group_outdated_consents_interval_in_seconds =
## Expire Berlin Group consents with status "valid"
# berlin_group_expired_consents_interval_in_seconds =
## Enable writing API metrics (which APIs are called) to RDBMS
@ -1124,7 +1128,7 @@ default_auth_context_update_request_key=CUSTOMER_NUMBER
#berlin_group_error_message_show_path = true
# Check presence of the mandatory headers
#berlin_group_mandatory_headers = X-Request-ID,PSU-IP-Address,PSU-Device-ID,PSU-Device-Name
#berlin_group_mandatory_headers = Content-Type,Date,Digest,PSU-Device-ID,PSU-Device-Name,PSU-IP-Address,Signature,TPP-Signature-Certificate,X-Request-ID
#berlin_group_mandatory_header_consent = TPP-Redirect-URL
## Berlin Group Create Consent Frequency per Day Upper Limit

View File

@ -5508,6 +5508,9 @@ object SwaggerDefinitionsJSON {
val consumerLogoUrlJson = ConsumerLogoUrlJson(
"http://localhost:8888"
)
val consumerCertificateJson = ConsumerCertificateJson(
"QmFnIEF0dHJpYnV0ZXMNCiAgICBsb2NhbEtleUlEOiBFMSA3RiBCMyBCOCBEQiA4QyA2NCBGNiA4QyA1NSAzNCA3QSAyNiBCRSBEMCBCNCBENCBBMyBGRCA2NiANCnN1YmplY3Q9QyA9IE1ELCBPID0gTUFJQiwgQ04gPSBNQUlCIFByaXNhY2FydSBTZXJnaXUgKFRlc3QpDQoNCmlzc3Vlcj1DID0gTUQsIE8gPSBCTk0sIE9VID0gRFRJLCBDTiA9IEJOTSBDQSAodGVzdCksIGVtYWlsQWRkcmVzcyA9IGFkbWluQGJubS5tZA0KDQotLS0tLUJFR0lOIENFUlRJRklDQVRFLS0tLS0NCk1JSUdoVENDQkcyZ0F3SUJBZ0lDQkRvd0RRWUpLb1pJaHZjTkFRRUZCUUF3WGpFTE1Ba0dBMVVFQmhNQ1RVUXgNCkREQUtCZ05WQkFvTUEwSk9UVEVNTUFvR0ExVUVDd3dEUkZSSk1SWXdGQVlEVlFRRERBMUNUazBnUTBFZ0tIUmwNCmMzUXBNUnN3R1FZSktvWklodmNOQVFrQkZneGhaRzFwYmtCaWJtMHViV1F3SGhjTk1qUXdOREU0TVRFME5qUXgNCldoY05Nall3TkRFNE1URTBOalF4V2pCRE1Rc3dDUVlEVlFRR0V3Sk5SREVOTUFzR0ExVUVDZ3dFVFVGSlFqRWwNCk1DTUdBMVVFQXd3Y1RVRkpRaUJRY21sellXTmhjblVnVTJWeVoybDFJQ2hVWlhOMEtUQ0NBU0l3RFFZSktvWkkNCmh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTFdYMzlFSmZLNEg5MDZKSVpMbHRxTU56amxDd3NyMm0rZjMNCjVYdHZ4SVY1akEvUWlZSDdDVjBQK0E1U2grKytaNldUb1NnQStQemYwdTdWYWRVbWtyWEZBV0lzOXlPemduUjQNCmZ5TVVSNXR4UWJYdmZYcXVJUS9XQ0ZnRHBIU3I4eWN0UHlsOGdsUjFidVF0UmlTdEdMT0RnalhsTmhTMlhTYTMNCmFwVGhUVHAya3o1dEoyWjBXRnlxa1ZVM1FJNkdNVGU5eWhDdnVZQkI1QWJuUUU4bXVPb2NNaEJkRFREY2ZGdW0NCk5paUozelhLMXZzKzEzNW5sZEMxOXozWnBuaVBSeER2WGthR00wc0xiNnk5T1NIOUdmYTZHcXJnendTTmpubEkNCnZCeWFlK1dtbG16TzlBZXVKNVRaUFhPdzNwcFdpTWdTOVlZOWp1UUtFQUFBQUFBQTQ4c0NBd0VBQWFPQ0FtWXcNCmdnSmlNQkVHQ1dDR1NBR0crRUlCQVFRRUF3SUZvREFwQmdOVkhTVUVJakFnQmdnckJnRUZCUWNEQWdZSUt3WUINCkJRVUhBd1FHQ2lzR0FRUUJnamNVQWdJd0hRWURWUjBPQkJZRUZGR2ptcXM4OXUyMXcvZmNHVlgrb0pNZSsvWTYNCk1JR1FCZ05WSFNNRWdZZ3dnWVdBRkh1ckdvcWhWYVFUVkJwRVlObmNnRUl5Vkd3dG9XS2tZREJlTVFzd0NRWUQNClZRUUdFd0pOUkRFTU1Bb0dBMVVFQ2d3RFFrNU5NUXd3Q2dZRFZRUUxEQU5FVkVreEZqQVVCZ05WQkFNTURVSk8NClRTQkRRU0FvZEdWemRDa3hHekFaQmdrcWhraUc5dzBCQ1FFV0RHRmtiV2x1UUdKdWJTNXRaSUlKQUpuU0UxdVoNCkU1MU5NQlFHQTFVZEVnUU5NQXVCQ1VOQlFHSnViUzV0WkRBMkJnbGdoa2dCaHZoQ0FRUUVLUlluYUhSMGNEb3YNCkwzQnJhUzVpYm0wdWJXUXZjR3RwTDNCMVlpOWpjbXd2WTJGamNtd3VZM0pzTURZR0NXQ0dTQUdHK0VJQkF3UXANCkZpZG9kSFJ3T2k4dmNHdHBMbUp1YlM1dFpDOXdhMmt2Y0hWaUwyTnliQzlqWVdOeWJDNWpjbXd3T0FZRFZSMGYNCkJERXdMekF0b0N1Z0tZWW5hSFIwY0RvdkwzQnJhUzVpYm0wdWJXUXZjR3RwTDNCMVlpOWpjbXd2WTJGamNtd3UNClkzSnNNRU1HQ0NzR0FRVUZCd0VCQkRjd05UQXpCZ2dyQmdFRkJRY3dBb1luYUhSMGNEb3ZMM2QzZHk1aWJtMHUNCmJXUXZjSFZpTDJOaFkyVnlkQzlqWVdObGNuUXVZM0owTUZBR0NXQ0dTQUdHK0VJQkRRUkRGa0ZMWlhrZ1VHRnANCmNpQkhaVzVsY21GMFpXUWdZbmtnVlc1cFEzSjVjSFFnZGk0d0xqWXVPQzQySUdadmNpQlFTME5USXpFeUxXWnANCmJHVWdjM1J2Y21GblpUQUpCZ05WSFJNRUFqQUFNQTRHQTFVZER3RUIvd1FFQXdJR1FEQU5CZ2txaGtpRzl3MEINCkFRVUZBQU9DQWdFQUpTU0ZhRWZOOWVna2wyYVFEc3QvVEtWWmxSbFdWZWkrVmZwMnM1ZXpWNG9ibnZRUXI5QkcNCmZrNklqaU8zbGZHTjQyTkVZSTV6SGh3SDl2WTRiMjM2ZkdMZWltbmZDc2lGb0FyTEtGUDR6Y0dvS0ZJR2ZBNDINCnQzSmxIcENvbmNpMmxqUzg4MzN2c1k5M2xGSzFTa2NvUjBMT0s0NzdaNlBWMjVtdjVjdmhCN1ZkNWs4SWpLU3MNCllwWkpaSi9STWZNT3dPQUtqeDFhWDNxQUhhNVhTOUNINEJaMEl4SnBYcWZpMm5GUFVNRy8yU0JmSTN4dDhsM1UNClJtVy9qZVRoRG5tL0Vsb05sb3pObzdRS3AvbysyUVBFZDBUWkFBdUljQWFiM09waUptOWlrUlh3c21mNkFmS0INCnIwQmtHcTFiTi9RQk1DMDM4RHA4S1pKZmdmaTYxYnBiVUNFdDRsVWY0R252TW9FdjZnbTh1czE2VTI1d0Y0SUwNCnd5cmFBZHJUVHhaWEVydGY2c3pWY2JBRUY0QmdFM0hCVmF2V2FxbDZac1FFRFJoTGVtWVJwMHhleUtwYXI4d3INClhqN1oycmJteWpFci9ES1hMdHF2UlFIQVVrVDBEQXRST2R4NmpsNUtGSFVvbTM2QUZmeU5UcjJ6a0p2MkZWTlENCmc0TnJMRnk0WldidE84ZDc2M2NoMEpjaWYzZUdadnFmQnVETUs3Q25jUWluamxVcTg1cFpzeGlFUW56VTJOdGgNClRFUzBqZjZ6ZS9ibHpVaUsrRXlyeWpEeWNaYlk3RHlwWWVlTlJJbk9zVUVjZmtFT3BVL3dFTG83dnpNaGY1b2MNCmdjcUFKSzdOQWlEQzVHR0Iyb296ZzNSTTJBbGdPT1ZpRFZwRzRMaUxPenpqVStqaXlyclY3OGs9DQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tDQo="
)
val consumerNameJson = ConsumerNameJson(
"App name"
)

View File

@ -242,10 +242,10 @@ recurringIndicator:
for {
(Full(user), callContext) <- authenticatedAccess(cc)
_ <- passesPsd2Aisp(callContext)
consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map {
_ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map {
unboxFullOrFail(_, callContext, ConsentNotFound)
}
consent <- Future(Consents.consentProvider.vend.revoke(consentId)) map {
_ <- Future(Consents.consentProvider.vend.revokeBerlinGroupConsent(consentId)) map {
i => connectorEmptyResponse(i, callContext)
}
} yield {
@ -694,8 +694,10 @@ where the consent was directly managed between ASPSP and PSU e.g. in a re-direct
consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map {
unboxFullOrFail(_, callContext, s"$ConsentNotFound ($consentId)")
}
_ <- Helper.booleanToFuture(failMsg = s"${consent.mConsumerId.get} != ${cc.consumer.map(_.consumerId.get).getOrElse("None")}", failCode = 404, cc = cc.callContext) {
consent.mConsumerId.get == callContext.map(_.consumer.map(_.consumerId.get).getOrElse("None")).getOrElse("None")
consumerIdFromConsent = consent.mConsumerId.get
consumerIdFromCurrentCall = callContext.map(_.consumer.map(_.consumerId.get).getOrElse("None")).getOrElse("None")
_ <- Helper.booleanToFuture(failMsg = s"$ConsentNotFound $consumerIdFromConsent != $consumerIdFromCurrentCall", failCode = 403, cc = cc.callContext) {
consumerIdFromConsent == consumerIdFromCurrentCall
}
} yield {
(createGetConsentResponseJson(consent), HttpCode.`200`(callContext))
@ -767,8 +769,7 @@ This method returns the SCA status of a consent initiation's authorisation sub-r
unboxFullOrFail(_, callContext, ConsentNotFound)
}
} yield {
val status = consent.status.toLowerCase()
.replace(ConsentStatus.REVOKED.toString.toLowerCase(), "revokedByPsu")
val status = consent.status
(JSONFactory_BERLIN_GROUP_1_3.ConsentStatusJsonV13(status), HttpCode.`200`(callContext))
}

View File

@ -48,19 +48,19 @@ object BgSpecValidation {
// Example usage
def main(args: Array[String]): Unit = {
val testDates = Seq(
"2025-05-10", // More than 180 days ahead
"9999-12-31", // Exceeds max allowed
"2015-01-01", // In the past
"invalid-date", // Invalid format
LocalDate.now().plusDays(90).toString, // Valid (within 180 days)
LocalDate.now().plusDays(180).toString, // Valid (exactly 180 days)
LocalDate.now().plusDays(181).toString // More than 180 days
"2025-05-10", // More than 180 days ahead
"9999-12-31", // Exceeds max allowed
"2015-01-01", // In the past
"invalid-date", // Invalid format
LocalDate.now().plusDays(90).toString, // Valid (within 180 days)
LocalDate.now().plusDays(180).toString, // Valid (exactly 180 days)
LocalDate.now().plusDays(181).toString // More than 180 days
)
testDates.foreach { date =>
validateValidUntil(date) match {
case Right(validDate) => println(s"Valid date: $validDate")
case Left(error) => println(s"Error: $error")
case Right(validDate) => println(s"Valid date: $validDate")
case Left(error) => println(s"Error: $error")
}
}
}

View File

@ -254,16 +254,6 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
case _ => None
}
}
/**
* Purpose of this helper function is to get the PSD2-CERT value from a Request Headers.
* @return the PSD2-CERT value from a Request Header as a String
*/
def getTppSignatureCertificate(requestHeaders: List[HTTPParam]): Option[String] = {
requestHeaders.toSet.filter(_.name == RequestHeader.`TPP-Signature-Certificate`).toList match {
case x :: Nil => Some(x.values.mkString(", "))
case _ => None
}
}
def getRequestHeader(name: String, requestHeaders: List[HTTPParam]): String = {
requestHeaders.toSet.filter(_.name.toLowerCase == name.toLowerCase).toList match {
@ -527,8 +517,16 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
*
*/
def getRequestHeadersToMirror(callContext: Option[CallContextLight]): CustomResponseHeaders = {
val mirrorByProperties = getPropsValue("mirror_request_headers_to_response", "").split(",").toList.map(_.trim)
val mirrorRequestHeadersToResponse: List[String] =
getPropsValue("mirror_request_headers_to_response", "").split(",").toList.map(_.trim)
if (callContext.exists(_.url.contains(ApiVersion.berlinGroupV13.urlPrefix))) {
// Berlin Group Specification
RequestHeader.`X-Request-ID` :: mirrorByProperties
} else {
mirrorByProperties
}
callContext match {
case Some(cc) =>
cc.requestHeaders match {
@ -536,13 +534,14 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
case _ =>
val headers = cc.requestHeaders
.filter(item => mirrorRequestHeadersToResponse.contains(item.name))
.map(item => (item.name, item.values.head))
.map(item => (item.name, item.values.headOption.getOrElse(""))) // Safe extraction
CustomResponseHeaders(headers)
}
case None =>
CustomResponseHeaders(Nil)
}
}
/**
*
*/
@ -2993,15 +2992,23 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
val remoteIpAddress = getRemoteIpAddress()
val authHeaders = AuthorisationUtil.getAuthorisationHeaders(reqHeaders)
val authHeadersWithEmptyValues = RequestHeadersUtil.checkEmptyRequestHeaderValues(reqHeaders)
val authHeadersWithEmptyNames = RequestHeadersUtil.checkEmptyRequestHeaderNames(reqHeaders)
// Identify consumer via certificate
val consumerByCertificate = Consent.getCurrentConsumerViaMtls(callContext = cc)
val consumerByCertificate = Consent.getCurrentConsumerViaTppSignatureCertOrMtls(callContext = cc)
val res =
if (authHeaders.size > 1) { // Check Authorization Headers ambiguity
if (authHeadersWithEmptyValues.nonEmpty) { // Check Authorization Headers Empty Values
val message = ErrorMessages.EmptyRequestHeaders + s"Header names: ${authHeadersWithEmptyValues.mkString(", ")}"
Future { (fullBoxOrException(Empty ~> APIFailureNewStyle(message, 400, Some(cc.toLight))), None) }
} else if (authHeadersWithEmptyNames.nonEmpty) { // Check Authorization Headers Empty Names
val message = ErrorMessages.EmptyRequestHeaders + s"Header values: ${authHeadersWithEmptyNames.mkString(", ")}"
Future { (fullBoxOrException(Empty ~> APIFailureNewStyle(message, 400, Some(cc.toLight))), None) }
} else if (authHeaders.size > 1) { // Check Authorization Headers ambiguity
Future { (Failure(ErrorMessages.AuthorizationHeaderAmbiguity + s"${authHeaders}"), None) }
} else if (APIUtil.`hasConsent-ID`(reqHeaders)) { // Berlin Group's Consent
Consent.applyBerlinGroupRules(APIUtil.`getConsent-ID`(reqHeaders), cc)
Consent.applyBerlinGroupRules(APIUtil.`getConsent-ID`(reqHeaders), cc.copy(consumer = consumerByCertificate))
} else if (APIUtil.hasConsentJWT(reqHeaders)) { // Open Bank Project's Consent
val consentValue = APIUtil.getConsentJWT(reqHeaders)
Consent.getConsentJwtValueByConsentId(consentValue.getOrElse("")) match {
@ -3011,12 +3018,12 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
// Note: At this point we are getting the Consumer from the Consumer in the Consent.
// This may later be cross checked via the value in consumer_validation_method_for_consent.
// Get the source of truth for Consumer (e.g. CONSUMER_CERTIFICATE) as early as possible.
cc.copy(consumer = Consent.getCurrentConsumerViaMtls(callContext = cc))
cc.copy(consumer = consumerByCertificate)
)
case _ =>
JwtUtil.checkIfStringIsJWTValue(consentValue.getOrElse("")).isDefined match {
case true => // It's JWT obtained via "Consent-JWT" request header
Consent.applyRules(APIUtil.getConsentJWT(reqHeaders), cc)
Consent.applyRules(APIUtil.getConsentJWT(reqHeaders), cc.copy(consumer = consumerByCertificate))
case false => // Unrecognised consent value
Future { (Failure(ErrorMessages.ConsentHeaderValueInvalid), None) }
}
@ -3115,8 +3122,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
}
else if(Option(cc).flatMap(_.user).isDefined) {
Future{(cc.user, Some(cc))}
}
else {
} else {
if(hasAuthorizationHeader(reqHeaders)) {
// We want to throw error in case of wrong or unsupported header. For instance:
// - Authorization: mF_9.B5f-4.1JqM

View File

@ -243,6 +243,8 @@ object ApiRole extends MdcLoggable{
case class CanUpdateConsumerLogoUrl(requiresBankId: Boolean = false) extends ApiRole
lazy val canUpdateConsumerLogoUrl = CanUpdateConsumerLogoUrl()
case class CanUpdateConsumerCertificate(requiresBankId: Boolean = false) extends ApiRole
lazy val canUpdateConsumerCertificate = CanUpdateConsumerCertificate()
case class CanUpdateConsumerName(requiresBankId: Boolean = false) extends ApiRole
lazy val canUpdateConsumerName = CanUpdateConsumerName()

View File

@ -9,8 +9,10 @@ import net.liftweb.http.provider.HTTPParam
object BerlinGroupCheck {
private val defaultMandatoryHeaders = "Content-Type,Date,Digest,PSU-Device-ID,PSU-Device-Name,PSU-IP-Address,Signature,TPP-Signature-Certificate,X-Request-ID"
// Parse mandatory headers from a comma-separated string
private val berlinGroupMandatoryHeaders: List[String] = APIUtil.getPropsValue("berlin_group_mandatory_headers", defaultValue = "X-Request-ID,PSU-IP-Address,PSU-Device-ID,PSU-Device-Name")
private val berlinGroupMandatoryHeaders: List[String] = APIUtil.getPropsValue("berlin_group_mandatory_headers", defaultValue = defaultMandatoryHeaders)
.split(",")
.map(_.trim.toLowerCase)
.toList.filterNot(_.isEmpty)

View File

@ -56,6 +56,9 @@ object BerlinGroupError {
case "401" if message.contains("OBP-20207") => "PSU_CREDENTIALS_INVALID"
case "401" if message.contains("OBP-20204") => "TOKEN_EXPIRED"
case "401" if message.contains("OBP-20215") => "TOKEN_INVALID"
case "401" if message.contains("OBP-20205") => "TOKEN_INVALID"
case "401" if message.contains("OBP-20204") => "TOKEN_INVALID"
case "401" if message.contains("OBP-35003") => "CONSENT_EXPIRED"
@ -66,6 +69,11 @@ object BerlinGroupError {
case "401" if message.contains("OBP-35018") => "CONSENT_INVALID"
case "401" if message.contains("OBP-35005") => "CONSENT_INVALID"
case "403" if message.contains("OBP-35001") => "CONSENT_UNKNOWN"
case "401" if message.contains("OBP-20312") => "CERTIFICATE_INVALID"
case "401" if message.contains("OBP-20310") => "SIGNATURE_INVALID"
case "401" if message.contains("OBP-20060") => "ROLE_INVALID"
case "400" if message.contains("OBP-35018") => "CONSENT_UNKNOWN"
@ -78,6 +86,8 @@ object BerlinGroupError {
case "400" if message.contains("OBP-10001") => "FORMAT_ERROR"
case "400" if message.contains("OBP-20062") => "FORMAT_ERROR"
case "400" if message.contains("OBP-20063") => "FORMAT_ERROR"
case "400" if message.contains("OBP-20252") => "FORMAT_ERROR"
case "400" if message.contains("OBP-20251") => "FORMAT_ERROR"
case "429" if message.contains("OBP-10018") => "ACCESS_EXCEEDED"
case _ => code

View File

@ -156,7 +156,8 @@ object BerlinGroupSigning extends MdcLoggable {
}
def getHeaderValue(name: String, requestHeaders: List[HTTPParam]): String = {
requestHeaders.find(_.name.toLowerCase() == name.toLowerCase()).map(_.values.mkString).getOrElse("None")
requestHeaders.find(_.name.toLowerCase() == name.toLowerCase()).map(_.values.mkString)
.getOrElse(SecureRandomUtil.csprng.nextLong().toString)
}
private def getPem(requestHeaders: List[HTTPParam]): String = {
val certificate = getHeaderValue(RequestHeader.`TPP-Signature-Certificate`, requestHeaders)

View File

@ -4,6 +4,7 @@ import java.text.SimpleDateFormat
import java.util.{Date, UUID}
import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{ConsentAccessJson, PostConsentJson}
import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank}
import code.api.util.BerlinGroupSigning.getHeaderValue
import code.api.util.ErrorMessages.{CouldNotAssignAccountAccess, InvalidConnectorResponse, NoViewReadAccountsBerlinGroup}
import code.api.v3_1_0.{PostConsentBodyCommonJson, PostConsentEntitlementJsonV310, PostConsentViewJsonV310}
import code.api.v5_0_0.HelperInfoJson
@ -29,6 +30,7 @@ import net.liftweb.json.JsonParser.ParseException
import net.liftweb.json.{Extraction, MappingException, compactRender, parse}
import net.liftweb.mapper.By
import net.liftweb.util.{ControlHelpers, Props}
import org.apache.commons.lang3.StringUtils
import sh.ory.hydra.model.OAuth2TokenIntrospection
import scala.collection.immutable.{List, Nil}
@ -126,24 +128,46 @@ object Consent extends MdcLoggable {
case _ => None
}
}
/**
* Purpose of this helper function is to get the Consumer via MTLS info i.e. PEM certificate.
* @return the boxed Consumer
*/
def getCurrentConsumerViaMtls(callContext: CallContext): Box[Consumer] = {
val clientCert: String = APIUtil.`getPSD2-CERT`(callContext.requestHeaders) // MTLS certificate QWAC (Qualified Website Authentication Certificate)
.orElse(BerlinGroupSigning.getTppSignatureCertificate(callContext.requestHeaders)) // Signature certificate QSealC (Qualified Electronic Seal Certificate)
.getOrElse(SecureRandomUtil.csprng.nextLong().toString) // Force to fail
{ // 1st search is via the original value
logger.debug(s"getConsumerByPemCertificate ${clientCert}")
Consumers.consumers.vend.getConsumerByPemCertificate(clientCert)
}.or { // 2nd search is via the original value we normalize
logger.debug(s"getConsumerByPemCertificate ${CertificateUtil.normalizePemX509Certificate(clientCert)}")
Consumers.consumers.vend.getConsumerByPemCertificate(CertificateUtil.normalizePemX509Certificate(clientCert))
/**
* Retrieves the current Consumer using either the MTLS (QWAC) certificate or the TPP signature certificate (QSealC).
* This method checks the request headers for the relevant PEM certificates and searches for the corresponding Consumer.
*
* @param callContext The request context containing headers.
* @return A Box containing the Consumer if found, otherwise Empty.
*/
def getCurrentConsumerViaTppSignatureCertOrMtls(callContext: CallContext): Box[Consumer] = {
{ // Attempt to get the Consumer via the TPP-Signature-Certificate (Qualified Electronic Seal Certificate - QSealC)
val tppSignatureCert: String = APIUtil.getRequestHeader(RequestHeader.`TPP-Signature-Certificate`, callContext.requestHeaders)
if (tppSignatureCert.isEmpty) {
logger.debug(s"| No `TPP-Signature-Certificate` header found |")
Empty // No `TPP-Signature-Certificate` header found, continue to MTLS check
} else {
logger.debug(s"Get Consumer By RequestHeader.`TPP-Signature-Certificate`: $tppSignatureCert")
Consumers.consumers.vend.getConsumerByPemCertificate(tppSignatureCert)
}
}.or { // If TPP certificate is not available, try to get Consumer via MTLS (Qualified Website Authentication Certificate - QWAC)
val psd2Cert: String = APIUtil.getRequestHeader(RequestHeader.`PSD2-CERT`, callContext.requestHeaders)
if (psd2Cert.isEmpty) {
logger.debug(s"| No `PSD2-CERT` header found |")
Empty // No `PSD2-CERT` header found
} else {
val consumerByPsd2Cert: Box[Consumer] = {
// First, try to find the Consumer using the original certificate value
logger.debug(s"Get Consumer By RequestHeader.`PSD2-CERT`: $psd2Cert")
Consumers.consumers.vend.getConsumerByPemCertificate(psd2Cert)
}.or {
// If the original value lookup fails, normalize the certificate and try again
val normalizedCert = CertificateUtil.normalizePemX509Certificate(psd2Cert)
logger.debug(s"Get Consumer By RequestHeader.`PSD2-CERT` (normalized): $normalizedCert")
Consumers.consumers.vend.getConsumerByPemCertificate(normalizedCert)
}
consumerByPsd2Cert
}
}
}
private def verifyHmacSignedJwt(jwtToken: String, c: MappedConsent): Boolean = {
logger.debug(s"code.api.util.Consent.verifyHmacSignedJwt beginning:: jwtToken($jwtToken), MappedConsent($c)")
val result = JwtUtil.verifyHmacSignedJwt(jwtToken, c.secret)
@ -151,16 +175,19 @@ object Consent extends MdcLoggable {
result
}
private def removeBreakLines(input: String) = input
.replace("\n", "")
.replace("\r", "")
private def checkConsumerIsActiveAndMatched(consent: ConsentJWT, callContext: CallContext): Box[Boolean] = {
val consumerBox = Consumers.consumers.vend.getConsumerByConsumerId(consent.aud)
logger.debug(s"code.api.util.Consent.checkConsumerIsActiveAndMatched.getConsumerByConsumerId consumerBox:: consumerBox($consumerBox)")
consumerBox match {
case Full(consumerFromConsent) if consumerFromConsent.isActive.get == true => // Consumer is active
val validationMetod = APIUtil.getPropsValue(nameOfProperty = "consumer_validation_method_for_consent", defaultValue = "CONSUMER_CERTIFICATE")
if(validationMetod != "CONSUMER_CERTIFICATE" && Props.mode == Props.RunModes.Production) {
logger.warn(s"consumer_validation_method_for_consent is not set to CONSUMER_CERTIFICATE! The current value is: ${validationMetod}")
val validationMethod = APIUtil.getPropsValue(nameOfProperty = "consumer_validation_method_for_consent", defaultValue = "CONSUMER_CERTIFICATE")
if(validationMethod != "CONSUMER_CERTIFICATE" && Props.mode == Props.RunModes.Production) {
logger.warn(s"consumer_validation_method_for_consent is not set to CONSUMER_CERTIFICATE! The current value is: ${validationMethod}")
}
validationMetod match {
validationMethod match {
case "CONSUMER_KEY_VALUE" =>
val requestHeaderConsumerKey = getConsumerKey(callContext.requestHeaders)
logger.debug(s"code.api.util.Consent.checkConsumerIsActiveAndMatched.consumerBox.requestHeaderConsumerKey:: requestHeaderConsumerKey($requestHeaderConsumerKey)")
@ -169,23 +196,29 @@ object Consent extends MdcLoggable {
if (reqHeaderConsumerKey == consumerFromConsent.key.get)
Full(true) // This consent can be used by current application
else // This consent can NOT be used by current application
Failure(ErrorMessages.ConsentDoesNotMatchConsumer)
Failure(s"${ErrorMessages.ConsentDoesNotMatchConsumer} CONSUMER_KEY_VALUE")
case None => Failure(ErrorMessages.ConsumerKeyHeaderMissing) // There is no header `Consumer-Key` in request headers
}
case "CONSUMER_CERTIFICATE" =>
val clientCert: String = APIUtil.`getPSD2-CERT`(callContext.requestHeaders).getOrElse(SecureRandomUtil.csprng.nextLong().toString)
logger.debug(s"code.api.util.Consent.checkConsumerIsActiveAndMatched.consumerBox clientCert:: clientCert($clientCert)")
def removeBreakLines(input: String) = input
.replace("\n", "")
.replace("\r", "")
val certificate = consumerFromConsent.clientCertificate
logger.debug(s"code.api.util.Consent.checkConsumerIsActiveAndMatched.consumer.certificate:: certificate($certificate)")
logger.debug(s"code.api.util.Consent.checkConsumerIsActiveAndMatched.consumer.certificate.dbNotNull_?(${certificate.dbNotNull_?})")
if (certificate.dbNotNull_? && removeBreakLines(clientCert) == removeBreakLines(certificate.get)) {
logger.debug(s"certificate.dbNotNull_? && removeBreakLines(clientCert) == removeBreakLines(consumerFromConsent.clientCertificate.get) result == true")
logger.debug(s"| Consent.checkConsumerIsActiveAndMatched | clientCert | $clientCert |")
logger.debug(s"| Consent.checkConsumerIsActiveAndMatched | consumerFromConsent.clientCertificate | ${consumerFromConsent.clientCertificate} |")
if (removeBreakLines(clientCert) == removeBreakLines(consumerFromConsent.clientCertificate.get)) {
logger.debug(s"| removeBreakLines(clientCert) == removeBreakLines(consumerFromConsent.clientCertificate.get | true |")
Full(true) // This consent can be used by current application
} else // This consent can NOT be used by current application
Failure(ErrorMessages.ConsentDoesNotMatchConsumer)
} else { // This consent can NOT be used by current application
Failure(s"${ErrorMessages.ConsentDoesNotMatchConsumer} CONSUMER_CERTIFICATE")
}
case "TPP_SIGNATURE_CERTIFICATE" =>
val tppSignatureCertificate = getHeaderValue(RequestHeader.`TPP-Signature-Certificate`, callContext.requestHeaders)
logger.debug(s"| Consent.checkConsumerIsActiveAndMatched | tppSignatureCertificate | $tppSignatureCertificate |")
logger.debug(s"| Consent.checkConsumerIsActiveAndMatched | consumerFromConsent.clientCertificate | ${consumerFromConsent.clientCertificate} |")
if (removeBreakLines(tppSignatureCertificate) == removeBreakLines(consumerFromConsent.clientCertificate.get)) {
logger.debug(s"""| removeBreakLines(tppSignatureCertificate) == removeBreakLines(consumerFromConsent.clientCertificate.get | true |""")
Full(true) // This consent can be used by current application
} else { // This consent can NOT be used by current application
Failure(s"${ErrorMessages.ConsentDoesNotMatchConsumer} TPP_SIGNATURE_CERTIFICATE")
}
case "NONE" => // This instance does not require validation method
Full(true)
case _ => // This instance does not specify validation method
@ -225,7 +258,7 @@ object Consent extends MdcLoggable {
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 _ =>
case _ =>
Failure(ErrorMessages.ConsentNotFound)
}
logger.debug(s"code.api.util.Consent.checkConsent.consentBox.result: result($result)")
@ -268,7 +301,7 @@ object Consent extends MdcLoggable {
val bankId = if (role.requiresBankId) entitlement.bank_id else ""
Entitlement.entitlement.vend.addEntitlement(bankId, user.userId, entitlement.role_name) match {
case Full(_) => (entitlement, "AddedOrExisted")
case _ =>
case _ =>
(entitlement, "Cannot add the entitlement: " + entitlement)
}
case true =>
@ -291,7 +324,7 @@ object Consent extends MdcLoggable {
val failedToAdd: List[(Role, String)] = triedToAdd.filter(_._2 != "AddedOrExisted")
failedToAdd match {
case Nil => Full(user)
case _ =>
case _ =>
Failure("The entitlements cannot be added. " + failedToAdd.map(i => (i._1, i._2)).mkString(", "))
}
case _ =>
@ -315,7 +348,7 @@ object Consent extends MdcLoggable {
Views.views.vend.systemView(ViewId(view.view_id)) match {
case Full(systemView) =>
Views.views.vend.grantAccessToSystemView(BankId(view.bank_id), AccountId(view.account_id), systemView, user)
case _ =>
case _ =>
// It's not system view
Views.views.vend.grantAccessToCustomView(bankIdAccountIdViewId, user)
}
@ -327,7 +360,7 @@ object Consent extends MdcLoggable {
}
if (errorMessages.isEmpty) Full(user) else Failure(CouldNotAssignAccountAccess + errorMessages.mkString(", "))
}
private def applyConsentRulesCommonOldStyle(consentIdAsJwt: String, calContext: CallContext): Box[User] = {
implicit val dateFormats = CustomJsonFormats.formats
@ -372,8 +405,8 @@ object Consent extends MdcLoggable {
case _ =>
Failure("Cannot extract data from: " + consentIdAsJwt)
}
}
}
private def applyConsentRulesCommon(consentAsJwt: String, callContext: CallContext): Future[(Box[User], Option[CallContext])] = {
implicit val dateFormats = CustomJsonFormats.formats
@ -411,16 +444,13 @@ object Consent extends MdcLoggable {
logger.debug(s"applyConsentRulesCommon.Start of net.liftweb.json.parse(jsonAsString).extract[ConsentJWT]: $jsonAsString")
val consent = net.liftweb.json.parse(jsonAsString).extract[ConsentJWT]
logger.debug(s"applyConsentRulesCommon.End of net.liftweb.json.parse(jsonAsString).extract[ConsentJWT]: $consent")
// Set Consumer into Call Context
val consumer = getCurrentConsumerViaMtls(callContext)
val updatedCallContext = callContext.copy(consumer = consumer)
checkConsent(consent, consentAsJwt, updatedCallContext) match { // Check is it Consent-JWT expired
checkConsent(consent, consentAsJwt, callContext) match { // Check is it Consent-JWT expired
case (Full(true)) => // OK
applyConsentRules(consent)
case failure@Failure(_, _, _) => // Handled errors
Future(failure, Some(updatedCallContext))
Future(failure, Some(callContext))
case _ => // Unexpected errors
Future(Failure(ErrorMessages.ConsentCheckExpiredIssue), Some(updatedCallContext))
Future(Failure(ErrorMessages.ConsentCheckExpiredIssue), Some(callContext))
}
} catch { // Possible exceptions
case e: ParseException => Future(Failure("ParseException: " + e.getMessage), Some(callContext))
@ -433,7 +463,7 @@ object Consent extends MdcLoggable {
Future(Failure("Cannot extract data from: " + consentAsJwt), Some(callContext))
}
}
def applyRules(consentJwt: Option[String], callContext: CallContext): Future[(Box[User], Option[CallContext])] = {
val allowed = APIUtil.getPropsAsBoolValue(nameOfProperty="consents.allowed", defaultValue=false)
(consentJwt, allowed) match {
@ -442,12 +472,12 @@ object Consent extends MdcLoggable {
case (None, _) => Future((Failure(ErrorMessages.ConsentHeaderNotFound), Some(callContext)))
}
}
def getConsentJwtValueByConsentId(consentId: String): Option[MappedConsent] = {
APIUtil.checkIfStringIsUUID(consentId) match {
case true => // String is a UUID
Consents.consentProvider.vend.getConsentByConsentId(consentId) match {
case Full(consent) => Some(consent)
case Full(consent) => Some(consent)
case _ => None // It's not valid UUID value
}
case false => None // It's not UUID at all
@ -493,7 +523,7 @@ object Consent extends MdcLoggable {
(Failure("Cannot create or get the user based on: " + consentId), Some(cc))
}
}
def checkFrequencyPerDay(storedConsent: consent.ConsentTrait) = {
def isSameDay(date1: Date, date2: Date): Boolean = {
val fmt = new SimpleDateFormat("yyyyMMdd")
@ -504,7 +534,7 @@ object Consent extends MdcLoggable {
case false => // The consent is for one access to the account data
if(usesSoFarTodayCounter == 0) // Maximum value is "1".
(true, 0) // All good
else
else
(false, 1) // Exceeded rate limit
case true => // The consent is for recurring access to the account data
if(!isSameDay(storedConsent.usesSoFarTodayCounterUpdatedAt, new Date())) {
@ -520,11 +550,9 @@ object Consent extends MdcLoggable {
// 1st we need to find a Consent via the field MappedConsent.consentId
Consents.consentProvider.vend.getConsentByConsentId(consentId) match {
case Full(storedConsent) =>
// Set Consumer into Call Context
val consumer = getCurrentConsumerViaMtls(callContext)
val user = Users.users.vend.getUserByUserId(storedConsent.userId)
logger.debug(s"applyBerlinGroupConsentRulesCommon.storedConsent.user : $user")
val updatedCallContext = callContext.copy(consumer = consumer).copy(consenter = user)
val updatedCallContext = callContext.copy(consenter = user)
// This function MUST be called only once per call. I.e. it's date dependent
val (canBeUsed, currentCounterState) = checkFrequencyPerDay(storedConsent)
if(canBeUsed) {

View File

@ -267,6 +267,7 @@ object ErrorMessages {
val AuthorizationHeaderAmbiguity = "OBP-20250: Request headers used for authorization are ambiguous. "
val MissingMandatoryBerlinGroupHeaders= "OBP-20251: Missing mandatory request headers. "
val EmptyRequestHeaders = "OBP-20252: Empty or null headers are not allowed. "
// X.509
val X509GeneralError = "OBP-20300: PEM Encoded Certificate issue."

View File

@ -740,8 +740,9 @@ object NewStyle extends MdcLoggable{
redirectURL: Option[String] = None,
createdByUserId: Option[String] = None,
logoURL: Option[String] = None,
certificate: Option[String] = None,
callContext: Option[CallContext]): Future[Consumer] = {
Future(Consumers.consumers.vend.updateConsumer(id, key, secret, isActive, name, appType, description, developerEmail, redirectURL, createdByUserId, logoURL)) map {
Future(Consumers.consumers.vend.updateConsumer(id, key, secret, isActive, name, appType, description, developerEmail, redirectURL, createdByUserId, logoURL, certificate)) map {
unboxFullOrFail(_, callContext, UpdateConsumerError, 404)
}
}

View File

@ -0,0 +1,22 @@
package code.api.util
import code.api.RequestHeader._
import net.liftweb.http.provider.HTTPParam
object RequestHeadersUtil {
def checkEmptyRequestHeaderValues(requestHeaders: List[HTTPParam]): List[String] = {
val emptyValues = requestHeaders
.filter(header => header != null && (header.values == null || header.values.isEmpty || header.values.exists(_.trim.isEmpty)))
.map(_.name) // Extract header names with empty values
emptyValues
}
def checkEmptyRequestHeaderNames(requestHeaders: List[HTTPParam]): List[String] = {
val emptyNames = requestHeaders
.filter(header => header == null || header.name == null || header.name.trim.isEmpty)
.map(_.values.mkString("'")) // List values without names
emptyNames
}
}

View File

@ -954,7 +954,7 @@ trait APIMethods210 {
case false => NewStyle.function.ownEntitlement("", u.userId, ApiRole.canDisableConsumers, cc.callContext)
}
consumer <- Consumers.consumers.vend.getConsumerByPrimaryId(consumerId.toLong)
updatedConsumer <- Consumers.consumers.vend.updateConsumer(consumer.id.get, None, None, Some(putData.enabled), None, None, None, None, None, None, None) ?~! "Cannot update Consumer"
updatedConsumer <- Consumers.consumers.vend.updateConsumer(consumer.id.get, None, None, Some(putData.enabled), None, None, None, None, None, None, None, None) ?~! "Cannot update Consumer"
} yield {
// Format the data as json
val json = PutEnabledJSON(updatedConsumer.isActive.get)

View File

@ -5997,7 +5997,7 @@ trait APIMethods310 {
}
consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, callContext)
updatedConsumer <- Future {
Consumers.consumers.vend.updateConsumer(consumer.id.get, None, None, Some(putData.enabled), None, None, None, None, None,None, None) ?~! "Cannot update Consumer"
Consumers.consumers.vend.updateConsumer(consumer.id.get, None, None, Some(putData.enabled), None, None, None, None, None,None, None, None) ?~! "Cannot update Consumer"
}
} yield {
// Format the data as json

View File

@ -1773,7 +1773,8 @@ trait APIMethods510 {
consent <- Future { Consents.consentProvider.vend.getConsentByConsentId(consentId)} map {
unboxFullOrFail(_, cc.callContext, ConsentNotFound, 404)
}
_ <- Helper.booleanToFuture(failMsg = s"${consent.mConsumerId.get} != ${cc.consumer.map(_.consumerId.get).getOrElse("None")}", failCode = 404, cc = cc.callContext) {
errorMessage = s" ${consent.mConsumerId.get} != ${cc.consumer.map(_.consumerId.get).getOrElse("None")}"
_ <- Helper.booleanToFuture(failMsg = ConsentNotFound + errorMessage, failCode = 404, cc = cc.callContext) {
consent.mConsumerId.get == cc.consumer.map(_.consumerId.get).getOrElse("None")
}
} yield {
@ -3197,6 +3198,53 @@ trait APIMethods510 {
}
}
}
staticResourceDocs += ResourceDoc(
updateConsumerCertificate,
implementedInApiVersion,
nameOf(updateConsumerCertificate),
"PUT",
"/management/consumers/CONSUMER_ID/consumer/certificate",
"Update Consumer Certificate",
s"""Update a Certificate for a Consumer specified by CONSUMER_ID.
|
| ${consumerDisabledText()}
|
| CONSUMER_ID can be obtained after you register the application.
|
| Or use the endpoint 'Get Consumers' to get it
|
""".stripMargin,
consumerCertificateJson,
consumerJsonV510,
List(
$UserNotLoggedIn,
UserHasMissingRoles,
UnknownError
),
List(apiTagConsumer),
Some(List(canUpdateConsumerCertificate))
)
lazy val updateConsumerCertificate: OBPEndpoint = {
case "management" :: "consumers" :: consumerId :: "consumer" :: "certificate" :: Nil JsonPut json -> _ => {
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
(Full(u), callContext) <- authenticatedAccess(cc)
postJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, callContext) {
json.extract[ConsumerCertificateJson]
}
consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, callContext)
updatedConsumer <- NewStyle.function.updateConsumer(
id = consumer.id.get,
certificate = Some(postJson.certificate),
callContext = callContext
)
} yield {
(JSONFactory510.createConsumerJSON(updatedConsumer), HttpCode.`200`(callContext))
}
}
}
staticResourceDocs += ResourceDoc(
updateConsumerName,

View File

@ -559,6 +559,9 @@ case class APITags(
case class ConsumerLogoUrlJson(
logo_url: String
)
case class ConsumerCertificateJson(
certificate: String
)
case class ConsumerNameJson(app_name: String)
case class TransactionRequestJsonV510(

View File

@ -26,6 +26,7 @@ trait ConsentProvider {
def createObpConsent(user: User, challengeAnswer: String, consentRequestId:Option[String], consumer: Option[Consumer] = None): Box[MappedConsent]
def setJsonWebToken(consentId: String, jwt: String): Box[MappedConsent]
def revoke(consentId: String): Box[MappedConsent]
def revokeBerlinGroupConsent(consentId: String): Box[MappedConsent]
def checkAnswer(consentId: String, challenge: String): Box[MappedConsent]
def createBerlinGroupConsent(
user: Option[User],
@ -191,7 +192,7 @@ object ConsentStatus extends Enumeration {
type ConsentStatus = Value
val INITIATED, ACCEPTED, REJECTED, rejected, REVOKED,
// The following one only exist in case of BerlinGroup
received, valid, REVOKEDBYPSU, revokedByPsu, EXPIRED, expired, TERMINATEDBYTPP, terminatedByTpp,
received, valid, revokedByPsu, expired, terminatedByTpp,
//these added for UK Open Banking
AUTHORISED, AWAITINGAUTHORISATION = Value
}

View File

@ -230,7 +230,24 @@ object MappedConsentProvider extends ConsentProvider {
case _ =>
Failure(ErrorMessages.UnknownError)
}
}
}
override def revokeBerlinGroupConsent(consentId: String): Box[MappedConsent] = {
MappedConsent.find(By(MappedConsent.mConsentId, consentId)) match {
case Full(consent) if consent.status == ConsentStatus.terminatedByTpp.toString =>
Failure(ErrorMessages.ConsentAlreadyRevoked)
case Full(consent) =>
tryo(consent
.mStatus(ConsentStatus.terminatedByTpp.toString)
.mLastActionDate(now)
.saveMe())
case Empty =>
Empty ?~! ErrorMessages.ConsentNotFound
case Failure(msg, _, _) =>
Failure(msg)
case _ =>
Failure(ErrorMessages.UnknownError)
}
}
override def checkAnswer(consentId: String, challengeAnswer: String): Box[MappedConsent] = {
def isAnswerCorrect(expectedAnswerHashed: String, answer: String, salt: String) = {
val challengeAnswerHashed = BCrypt.hashpw(answer, salt).substring(0, 44)

View File

@ -48,6 +48,7 @@ trait ConsumersProvider {
redirectURL: Option[String],
createdByUserId: Option[String],
LogoURL: Option[String],
certificate: Option[String],
): Box[Consumer]
def updateConsumerCallLimits(id: Long, perSecond: Option[String], perMinute: Option[String], perHour: Option[String], perDay: Option[String], perWeek: Option[String], perMonth: Option[String]): Future[Box[Consumer]]
def getOrCreateConsumer(consumerId: Option[String],

View File

@ -238,7 +238,8 @@ object MappedConsumersProvider extends ConsumersProvider with MdcLoggable {
developerEmail: Option[String],
redirectURL: Option[String],
createdByUserId: Option[String],
logoURL: Option[String]
logoURL: Option[String],
certificate: Option[String],
): Box[Consumer] = {
val consumer = Consumer.find(By(Consumer.id, id))
consumer match {
@ -260,6 +261,10 @@ object MappedConsumersProvider extends ConsumersProvider with MdcLoggable {
case Some(v) => c.name(v)
case None =>
}
certificate match {
case Some(v) => c.clientCertificate(v)
case None =>
}
appType match {
case Some(v) => v match {
case Confidential => c.appType(Confidential.toString)

View File

@ -5,6 +5,7 @@ import code.api.util.APIUtil
import code.consent.{ConsentStatus, MappedConsent}
import code.util.Helper.MdcLoggable
import com.openbankproject.commons.util.ApiVersion
import net.liftweb.common.Full
import net.liftweb.mapper.{By, By_<}
import java.util.concurrent.TimeUnit
@ -21,8 +22,22 @@ object ConsentScheduler extends MdcLoggable {
// Starts multiple scheduled tasks with different intervals
def startAll(): Unit = {
startTask(interval = 60, () => unfinishedBerlinGroupConsents()) // Runs every 60 sec
startTask(interval = 60, () => expiredBerlinGroupConsents(), 10) // Start 10 seconds after previous job
APIUtil.getPropsAsIntValue("berlin_group_outdated_consents_interval_in_seconds") match {
case Full(interval) if interval > 0 =>
val time = APIUtil.getPropsAsIntValue("berlin_group_outdated_consents_time_in_seconds", 300)
startTask(interval = interval, () => unfinishedBerlinGroupConsents(time)) // Runs periodically
case _ =>
logger.warn("|---> Skipping unfinishedBerlinGroupConsents task: berlin_group_outdated_consents_interval_in_seconds not set or invalid")
}
APIUtil.getPropsAsIntValue("berlin_group_expired_consents_interval_in_seconds") match {
case Full(interval) if interval > 0 =>
startTask(interval = interval, () => expiredBerlinGroupConsents(), 10) // Delay for 10 seconds
case _ =>
logger.warn("|---> Skipping expiredBerlinGroupConsents task: berlin_group_expired_consents_interval_in_seconds not set or invalid")
}
}
// Generic method to schedule a task
@ -37,21 +52,20 @@ object ConsentScheduler extends MdcLoggable {
}
// Calculate the timestamp 5 minutes ago
private val someMinutesAgo: Date = {
val minutes = APIUtil.getPropsAsIntValue("berlin_group_outdated_consents_interval", 5)
private def someSecondsAgo(seconds: Int): Date = {
val cal = Calendar.getInstance()
cal.add(Calendar.MINUTE, -minutes)
cal.add(Calendar.SECOND, -seconds)
cal.getTime
}
private def unfinishedBerlinGroupConsents(): Unit = {
private def unfinishedBerlinGroupConsents(seconds: Int): Unit = {
Try {
logger.debug("|---> Checking for outdated Berlin Group consents...")
val outdatedConsents = MappedConsent.findAll(
By(MappedConsent.mStatus, ConsentStatus.received.toString),
By(MappedConsent.mApiStandard, ApiVersion.berlinGroupV13.apiStandard),
By_<(MappedConsent.updatedAt, someMinutesAgo)
By_<(MappedConsent.updatedAt, someSecondsAgo(seconds))
)
logger.debug(s"|---> Found ${outdatedConsents.size} outdated consents")

View File

@ -75,8 +75,8 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
}
}
private object accessAccountsDefinedVar extends SessionVar(true)
private object accessBalancesDefinedVar extends SessionVar(true)
private object accessTransactionsDefinedVar extends SessionVar(true)
private object accessBalancesDefinedVar extends SessionVar(false)
private object accessTransactionsDefinedVar extends SessionVar(false)
/**
* Creates a ConsentAccessJson object from lists of IBANs for accounts, balances, and transactions.
*
@ -167,9 +167,9 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
// Determine which IBANs the user can access for accounts, balances, and transactions
val canReadAccountsIbans: List[String] = json.access.accounts match {
case Some(accounts) if accounts.isEmpty => // Access is requested
case Some(accounts) if accounts.isEmpty => // Access is requested via "accounts": []
updateConsentPayloadValue.set(true)
accessAccountsDefinedVar.set(true)
accessAccountsDefinedVar.set(true) // only account details access will be provided
List()
case Some(accounts) if accounts.flatMap(_.iban).toSet.subsetOf(userIbans) => // Access is requested for specific IBANs
accessAccountsDefinedVar.set(true)
@ -183,8 +183,10 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
List()
}
val canReadBalancesIbans: List[String] = json.access.balances match {
case Some(balances) if balances.isEmpty => // Access is requested
case Some(balances) if balances.isEmpty => // Access is requested via "balances": []
updateConsentPayloadValue.set(true)
// access to account details and balances will be provided
accessAccountsDefinedVar.set(true)
accessBalancesDefinedVar.set(true)
List()
case Some(balances) if balances.flatMap(_.iban).toSet.subsetOf(userIbans) => // Access is requested for specific IBANs
@ -199,8 +201,10 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
List()
}
val canReadTransactionsIbans: List[String] = json.access.transactions match {
case Some(transactions) if transactions.isEmpty => // Access is requested
case Some(transactions) if transactions.isEmpty => // Access is requested via "transactions": []
updateConsentPayloadValue.set(true)
// access to account details and transactions will be provided
accessAccountsDefinedVar.set(true)
accessTransactionsDefinedVar.set(true)
List()
case Some(transactions) if transactions.flatMap(_.iban).toSet.subsetOf(userIbans) => // Access is requested for specific IBANs

View File

@ -8,6 +8,7 @@ import code.api.util.APIUtil
import code.api.util.APIUtil.OAuth._
import code.api.util.ErrorMessages._
import code.api.v4_0_0.PostViewJsonV400
import code.consent.ConsentStatus
import code.model.dataAccess.{BankAccountRouting, MappedBankAccount}
import code.setup.{APIResponse, DefaultUsers}
import com.github.dwickern.macros.NameOf.nameOf
@ -262,7 +263,7 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit
Then("We should get a 201 ")
response.code should equal(201)
response.body.extract[PostConsentResponseJson].consentId should not be (empty)
response.body.extract[PostConsentResponseJson].consentStatus should be ("received")
response.body.extract[PostConsentResponseJson].consentStatus should be (ConsentStatus.received.toString)
}
}
@ -302,10 +303,16 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit
val consentId =response.body.extract[PostConsentResponseJson].consentId
Then("We test the delete consent ")
val requestDelete = (V1_3_BG / "consents"/consentId ).DELETE <@ (user1)
val requestDelete = (V1_3_BG / "consents"/ consentId ).DELETE <@ (user1)
val responseDelete = makeDeleteRequest(requestDelete)
responseDelete.code should be (204)
Then(s"We test the $getConsentStatus")
val requestGetStatus = (V1_3_BG / "consents" / consentId / "status").GET <@ (user1)
val responseGetStatus = makeGetRequest(requestGetStatus)
responseGetStatus.code should be(200)
responseGetStatus.body.extract[ConsentStatusJsonV13].consentStatus should be(ConsentStatus.terminatedByTpp.toString)
//TODO We can not delete one consent two time, will fix it later.
// val responseDeleteSecondTime = makeDeleteRequest(requestDelete)
// responseDeleteSecondTime.code should be (400)
@ -350,13 +357,13 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit
val requestGet = (V1_3_BG / "consents"/consentId ).GET <@ (user1)
val responseGet = makeGetRequest(requestGet)
responseGet.code should be (200)
responseGet.body.extract[GetConsentResponseJson].consentStatus should be ("received")
responseGet.body.extract[GetConsentResponseJson].consentStatus should be (ConsentStatus.received.toString)
Then(s"We test the $getConsentStatus")
val requestGetStatus = (V1_3_BG / "consents"/consentId /"status" ).GET <@ (user1)
val responseGetStatus = makeGetRequest(requestGetStatus)
responseGetStatus.code should be (200)
responseGetStatus.body.extract[ConsentStatusJsonV13].consentStatus should be ("received")
responseGetStatus.body.extract[ConsentStatusJsonV13].consentStatus should be (ConsentStatus.received.toString)
}
}
@ -398,7 +405,7 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit
val requestStartConsentAuthorisation = (V1_3_BG / "consents"/consentId /"authorisations" ).POST <@ (user1)
val responseStartConsentAuthorisation = makePostRequest(requestStartConsentAuthorisation, """{"scaAuthenticationData":""}""")
responseStartConsentAuthorisation.code should be (201)
responseStartConsentAuthorisation.body.extract[StartConsentAuthorisationJson].scaStatus should be ("received")
responseStartConsentAuthorisation.body.extract[StartConsentAuthorisationJson].scaStatus should be (ConsentStatus.received.toString)
}
}
@ -456,7 +463,7 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit
val requestStartConsentAuthorisation = (V1_3_BG / "consents"/consentId /"authorisations" ).POST <@ (user1)
val responseStartConsentAuthorisation = makePostRequest(requestStartConsentAuthorisation, """{"scaAuthenticationData":""}""")
responseStartConsentAuthorisation.code should be (201)
responseStartConsentAuthorisation.body.extract[StartConsentAuthorisationJson].scaStatus should be ("received")
responseStartConsentAuthorisation.body.extract[StartConsentAuthorisationJson].scaStatus should be (ConsentStatus.received.toString)
Then(s"We test the $getConsentAuthorisation")
val requestGetConsentAuthorisation = (V1_3_BG / "consents"/consentId /"authorisations" ).GET<@ (user1)
@ -469,7 +476,7 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit
val requestGetConsentScaStatus = (V1_3_BG / "consents"/consentId /"authorisations"/authorisationId ).GET <@ (user1)
val responseGetConsentScaStatus = makeGetRequest(requestGetConsentScaStatus)
responseGetConsentScaStatus.code should be (200)
responseGetConsentScaStatus.body.extract[ScaStatusJsonV13].scaStatus should be ("received")
responseGetConsentScaStatus.body.extract[ScaStatusJsonV13].scaStatus should be (ConsentStatus.received.toString)
}
}

View File

@ -27,7 +27,7 @@ package code.api.v5_1_0
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON
import code.api.util.APIUtil.OAuth._
import code.api.util.ApiRole.{canCreateConsumer, canGetConsumers, canUpdateConsumerLogoUrl, canUpdateConsumerName, canUpdateConsumerRedirectUrl}
import code.api.util.ApiRole.{canCreateConsumer, canGetConsumers, canUpdateConsumerCertificate, canUpdateConsumerLogoUrl, canUpdateConsumerName, canUpdateConsumerRedirectUrl}
import code.api.util.ErrorMessages.{InvalidJsonFormat, UserNotLoggedIn}
import code.api.v3_1_0.ConsumerJsonV310
import code.api.v5_1_0.OBPAPI5_1_0.Implementations5_1_0
@ -52,10 +52,11 @@ class ConsumerTest extends V510ServerSetup {
object ApiEndpoint3 extends Tag(nameOf(Implementations5_1_0.updateConsumerRedirectURL))
object ApiEndpoint4 extends Tag(nameOf(Implementations5_1_0.updateConsumerLogoURL))
object UpdateConsumerName extends Tag(nameOf(Implementations5_1_0.updateConsumerName))
object UpdateConsumerCertificate extends Tag(nameOf(Implementations5_1_0.updateConsumerCertificate))
object GetConsumer extends Tag(nameOf(Implementations5_1_0.getConsumer))
feature("Test all error cases ") {
scenario("We test the authentication errors", UpdateConsumerName, GetConsumer, ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, VersionOfApi) {
scenario("We test the authentication errors", UpdateConsumerName, GetConsumer, ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, UpdateConsumerCertificate, VersionOfApi) {
When("We make a request v5.1.0")
lazy val postApiCollectionJson = SwaggerDefinitionsJSON.postApiCollectionJson400
val requestApiEndpoint1 = (v5_1_0_Request / "management" / "consumers").POST
@ -73,6 +74,9 @@ class ConsumerTest extends V510ServerSetup {
val requestApiUpdateConsumerName = (v5_1_0_Request /"management" / "consumers" / "CONSUMER_ID" / "consumer" / "name").PUT
val responseApiUpdateConsumerName = makePutRequest(requestApiUpdateConsumerName, write(postApiCollectionJson))
val requestApiUpdateConsumerCertificate = (v5_1_0_Request /"management" / "consumers" / "CONSUMER_ID" / "consumer" / "certificate").PUT
val responseApiUpdateConsumerCertificate = makePutRequest(requestApiUpdateConsumerCertificate, write(postApiCollectionJson))
Then(s"we should get the error messages")
responseApiEndpoint1.code should equal(401)
responseApiEndpoint2.code should equal(401)
@ -86,6 +90,9 @@ class ConsumerTest extends V510ServerSetup {
responseApiUpdateConsumerName.code should equal(401)
responseApiUpdateConsumerName.body.toString contains(s"$UserNotLoggedIn") should be (true)
responseApiUpdateConsumerCertificate.code should equal(401)
responseApiUpdateConsumerCertificate.body.toString contains(s"$UserNotLoggedIn") should be (true)
// Endpoint GetConsumer
val requestApiEndpoint5 = (v5_1_0_Request / "management" / "consumers" / "whatever").GET
val responseApiEndpoint5 = makeGetRequest(requestApiEndpoint5)
@ -93,7 +100,7 @@ class ConsumerTest extends V510ServerSetup {
responseApiEndpoint5.body.toString contains(s"$UserNotLoggedIn") should be (true)
}
scenario("We test the missing roles errors", UpdateConsumerName, GetConsumer, ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, VersionOfApi) {
scenario("We test the missing roles errors", UpdateConsumerName, GetConsumer, ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, UpdateConsumerCertificate, VersionOfApi) {
When("We make a request v5.1.0")
lazy val wrongJsonForTesting = SwaggerDefinitionsJSON.routing
@ -111,7 +118,10 @@ class ConsumerTest extends V510ServerSetup {
val requestApiUpdateConsumerName = (v5_1_0_Request /"management" / "consumers" / "CONSUMER_ID" / "consumer" / "name").PUT<@ (user1)
val responseApiUpdateConsumerName = makePutRequest(requestApiUpdateConsumerName, write(wrongJsonForTesting))
val requestApiUpdateConsumerCertificate = (v5_1_0_Request / "management" / "consumers" / "CONSUMER_ID" / "consumer" / "certificate").PUT <@ (user1)
val responseApiUpdateConsumerCertificate = makePutRequest(requestApiUpdateConsumerCertificate, write(wrongJsonForTesting))
Then(s"we should get the error messages")
responseApiEndpoint1.code should equal(403)
responseApiEndpoint1.body.toString contains(s"$canCreateConsumer") should be (true)
@ -124,6 +134,9 @@ class ConsumerTest extends V510ServerSetup {
responseApiUpdateConsumerName.code should equal(403)
responseApiUpdateConsumerName.body.toString contains(s"$canUpdateConsumerName") should be (true)
responseApiUpdateConsumerCertificate.code should equal(403)
responseApiUpdateConsumerCertificate.body.toString contains(s"$canUpdateConsumerCertificate") should be (true)
// Endpoint GetConsumer
val requestApiEndpoint5 = (v5_1_0_Request / "management" / "consumers" / "whatever").GET <@ user1
val responseApiEndpoint5 = makeGetRequest(requestApiEndpoint5)
@ -137,6 +150,7 @@ class ConsumerTest extends V510ServerSetup {
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerLogoUrl.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerRedirectUrl.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerName.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerCertificate.toString)
When("We make a request v5.1.0")
lazy val wrongJsonForTesting = SwaggerDefinitionsJSON.postApiCollectionJson400
@ -152,6 +166,9 @@ class ConsumerTest extends V510ServerSetup {
val requestApiUpdateConsumerName = (v5_1_0_Request / "management" / "consumers" / "CONSUMER_ID" / "consumer" / "name").PUT <@ (user1)
val responseApiUpdateConsumerName = makePutRequest(requestApiUpdateConsumerName, write(wrongJsonForTesting))
val requestApiUpdateConsumerCertificate = (v5_1_0_Request / "management" / "consumers" / "CONSUMER_ID" / "consumer" / "certificate").PUT <@ (user1)
val responseApiUpdateConsumerCertificate = makePutRequest(requestApiUpdateConsumerCertificate, write(wrongJsonForTesting))
Then(s"we should get the error messages")
responseApiEndpoint1.code should equal(400)
responseApiEndpoint1.body.toString contains(s"$InvalidJsonFormat") should be (true)
@ -161,6 +178,9 @@ class ConsumerTest extends V510ServerSetup {
responseApiEndpoint4.body.toString contains(s"$InvalidJsonFormat") should be (true)
responseApiUpdateConsumerName.code should equal(400)
responseApiUpdateConsumerName.body.toString contains(s"$InvalidJsonFormat") should be (true)
responseApiUpdateConsumerCertificate.code should equal(400)
responseApiUpdateConsumerCertificate.body.toString contains(s"$InvalidJsonFormat") should be (true)
}
}
@ -173,11 +193,13 @@ class ConsumerTest extends V510ServerSetup {
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerLogoUrl.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerRedirectUrl.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerName.toString)
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerCertificate.toString)
lazy val createConsumerRequestJsonV510 = SwaggerDefinitionsJSON.createConsumerRequestJsonV510
lazy val consumerRedirectUrlJSON = SwaggerDefinitionsJSON.consumerRedirectUrlJSON
lazy val consumerLogoUrlJson = SwaggerDefinitionsJSON.consumerLogoUrlJson
lazy val consumerNameJson = SwaggerDefinitionsJSON.consumerNameJson
lazy val consumerCertificateJson = SwaggerDefinitionsJSON.consumerCertificateJson
val requestApiEndpoint1 = (v5_1_0_Request / "management" / "consumers").POST<@ (user1)
val responseApiEndpoint1 = makePostRequest(requestApiEndpoint1, write(createConsumerRequestJsonV510))
val consumerId = responseApiEndpoint1.body.extract[ConsumerJsonV510].consumer_id
@ -200,6 +222,11 @@ class ConsumerTest extends V510ServerSetup {
val responseApiUpdateConsumerName = makePutRequest(requestApiUpdateConsumerName, write(consumerNameJson))
val name = responseApiUpdateConsumerName.body.extract[ConsumerJsonV510].app_name
name shouldBe(consumerNameJson.app_name)
val requestApiUpdateConsumerCertificate = (v5_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "certificate").PUT <@ (user1)
val responseApiUpdateConsumerCertificate = makePutRequest(requestApiUpdateConsumerCertificate, write(consumerCertificateJson))
val certificatePem = responseApiUpdateConsumerCertificate.body.extract[ConsumerJsonV510].certificate_pem
certificatePem shouldBe(consumerCertificateJson.certificate)
Then(s"we should get the error messages")
responseApiEndpoint1.code should equal(201)