Merge remote-tracking branch 'refs/remotes/UPSTREAM/develop' into develop

# Conflicts:
#	.github/workflows/build_container_develop_branch.yml
#	.github/workflows/build_container_non_develop_branch.yml
#	.github/workflows/build_contributer_container.yml
#	.github/workflows/build_package.yml
This commit is contained in:
tawoe 2025-02-27 13:09:55 +01:00
commit eac4cf4a7a
16 changed files with 738 additions and 158 deletions

View File

@ -27,9 +27,9 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up JDK 11
uses: actions/setup-java@v2
uses: actions/setup-java@v4
with:
java-version: '11'
distribution: 'adopt'

View File

@ -19,7 +19,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- id: trivy-db
name: Check trivy db sha
env:
@ -31,7 +31,7 @@ jobs:
sha=$(gh api -H "${headers}" "${endpoint}" | jq --raw-output "${jqFilter}")
echo "Trivy DB sha256:${sha}"
echo "::set-output name=sha::${sha}"
- uses: actions/cache@v3
- uses: actions/cache@v4
with:
path: .trivy
key: ${{ runner.os }}-trivy-db-${{ steps.trivy-db.outputs.sha }}
@ -49,6 +49,6 @@ jobs:
- name: Fix .trivy permissions
run: sudo chown -R $(stat . -c %u:%g) .trivy
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v1
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'

View File

@ -0,0 +1,87 @@
### TPP Certificate Verification
The signing and verification flow of certificates for TPP requests is a follows:
#### Generation of the Request by TPP
**Step 1: Calculating the Hash for the Digest Field**
* TPP creates the body of the request (e.g., JSON containing transaction or consent data).
* TPP applies the SHA-256 algorithm to the body of the request to generate a unique hash.
* The resulting hash is encoded in Base64 and placed in the request header under the `Digest` field.
**Step 2: Creating the Signing String**
* TPP prepares a signing string based on specific fields from the request header.
* The signing string is a concatenation of the values of the signed fields in a specific format.
* The order of the fields is critical and follows the specifications.
**Step 3: Signing the Signing String**
* TPP uses its RSA private key associated with its certificate to generate a digital signature.
* The signature is applied to the signing string using the RSA-SHA256 algorithm.
* The resulting digital signature is Base64-encoded and placed in the `Signature` field.
**Step 4: Adding the Certificate to the TPP-Signature-Certificate Field**
* TPP includes its public certificate in the request header under the `TPP-Signature-Certificate` field.
* The certificate is issued by a trusted certification authority (CA) and contains the TPP's public key.
**Step 5: Sending the Request to OBP**
* TPP sends the complete request, including the `Digest`, `Signature`, and `TPP-Signature-Certificate` headers, to the OBP endpoint.
---
#### Verification of the Request by OBP
**Step 1: Validating the TPP Certificate**
* OBP verifies the TPP certificate included in the `TPP-Signature-Certificate` field.\
Steps:
1. Verify the certificate's trust chain up to a trusted certification authority.
2. Ensure the certificate is valid (not expired or revoked).
3. Confirm the certificate is issued for the appropriate usage (e.g., digital signatures for Open Banking), based on the information from the National Bank registry (e.g., certificate SN and CA found in the `Signature` field).
* **Result**: If the certificate is valid, the TPP's identity is confirmed.
**Step 2: Verifying the Signature in the `Signature` Field**
* OBP extracts the fields included in the signing string based on the information in the `Signature` field.
* OBP recreates the signing string in the same format used by the TPP.
* OBP uses the public key from the certificate in the `TPP-Signature-Certificate` field to verify the signature in the `Signature` field.
* **Result**: If the signature is valid, the authenticity of the request is confirmed.
**Step 3: Verifying the Hash in the `Digest` Field**
* OBP computes its own SHA-256 hash of the received request body.
* The computed hash is compared with the value in the `Digest` header.
* **Result**: If the two hashes match, the integrity of the request body is confirmed.
**Step 4: Authorizing the Request**
* If all verifications (integrity, signature, and certificate) pass, OBP processes the request.
* Otherwise, the request is rejected with an error message (e.g., `401 Unauthorized` or `403 Forbidden`).
---
#### Additional Details
**How does OBP verify the signature in the `Signature` field?**
1. **Extracts the public key from the `TPP-Signature-Certificate`**:
* OBP retrieves the certificate (`TPP-Signature-Certificate`) from the request header.
* From this certificate, OBP extracts the TPP's public key.
2. **Recreates the Signing String**:
* OBP analyzes the `Signature` header to identify the signed fields (e.g., `Digest`, `X-Request-ID`, `Date`, etc.).
* OBP recreates the signing string from the actual values of these fields in the specified order.
3. **Decodes the Signature**:
* OBP decodes the value in the `Signature.signature` field (the digital signature generated by TPP).
* Decoding is done using the public key extracted from the `TPP-Signature-Certificate` and the algorithm specified in `Signature.algorithm` (e.g., RSA-SHA256).
4. **Compares the Resulting Hash**:
* During decoding, OBP obtains a hash calculated by TPP at the time of signing.
* OBP compares this hash with the hash generated internally from the recreated signing string.
5. **Possible Outcomes**:
* If the hashes match, the signature is valid.
* If the hashes do not match, the signature is invalid, and the request is rejected.

View File

@ -178,6 +178,12 @@ jwt.use.ssl=false
# truststore.password.redis = truststore-password
## Trust stores is a list of trusted CA certificates
## Public certificate for the CA (used by clients and servers to validate signatures)
# truststore.path.tpp_signature = path/to/ca.p12
# truststore.password.tpp_signature = truststore-password
## Enable writing API metrics (which APIs are called) to RDBMS
write_metrics=true
## Enable writing connector metrics (which methods are called)to RDBMS

View File

@ -519,7 +519,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth
case Some(STATIC) => {
val cacheValueFromRedis = Caching.getStaticResourceDocCache(cacheKey)
val dynamicDocs: Box[JValue] =
val staticDocs: Box[JValue] =
if (cacheValueFromRedis.isDefined) {
Full(json.parse(cacheValueFromRedis.get))
} else {
@ -530,12 +530,12 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth
Full(resourceDocJsonJValue)
}
Future(dynamicDocs.map(successJsonResponse(_)))
Future(staticDocs.map(successJsonResponse(_)))
}
case _ => {
val cacheValueFromRedis = Caching.getAllResourceDocCache(cacheKey)
val dynamicDocs: Box[JValue] =
val bothStaticAndDyamicDocs: Box[JValue] =
if (cacheValueFromRedis.isDefined) {
Full(json.parse(cacheValueFromRedis.get))
} else {
@ -546,7 +546,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth
Full(resourceDocJsonJValue)
}
Future(dynamicDocs.map(successJsonResponse(_)))
Future(bothStaticAndDyamicDocs.map(successJsonResponse(_)))
}
}
}
@ -715,13 +715,11 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth
swaggerJValue <- NewStyle.function.tryons(s"$UnknownError Can not convert internal swagger file.", 400, cc.callContext) {
val cacheValueFromRedis = Caching.getStaticSwaggerDocCache(cacheKey)
val dynamicDocs: JValue =
if (cacheValueFromRedis.isDefined) {
json.parse(cacheValueFromRedis.get)
} else {
convertResourceDocsToSwaggerJvalueAndSetCache(cacheKey, requestedApiVersionString, resourceDocsJsonFiltered)
}
dynamicDocs
if (cacheValueFromRedis.isDefined) {
json.parse(cacheValueFromRedis.get)
} else {
convertResourceDocsToSwaggerJvalueAndSetCache(cacheKey, requestedApiVersionString, resourceDocsJsonFiltered)
}
}
} yield {
(swaggerJValue, HttpCode.`200`(cc.callContext))

View File

@ -61,7 +61,7 @@ object Redis extends MdcLoggable {
// Load the CA certificate
val trustStore = KeyStore.getInstance(KeyStore.getDefaultType)
val trustStorePassword = APIUtil.getPropsValue("keystore.password.redis")
val trustStorePassword = APIUtil.getPropsValue("truststore.password.redis")
.getOrElse(APIUtil.initPasswd).toCharArray
val truststorePath = APIUtil.getPropsValue("truststore.path.redis").getOrElse("")
val trustStoreStream = new FileInputStream(truststorePath)

View File

@ -12,47 +12,48 @@
"logo":"https://static.openbankproject.com/images/sandbox/bank_y.png",
"website":"https://www.example.com"
}],
"users":[{
"email":"robert.xuk.x@example.com",
"user_name": "robert.xuk.x@example.com",
"password":"5232e7",
"display_name":"Robert XUk X"
},{
"email":"susan.xuk.x@example.com",
"user_name": "susan.xuk.x@example.com",
"password":"43ca4d",
"display_name":"Susan XUk X"
},{
"email":"anil.xuk.x@example.com",
"user_name": "anil.xuk.x@example.com",
"password":"d8c716",
"display_name":"Anil XUk X"
},{
"email":"ellie.xuk.x@example.com",
"user_name": "ellie.xuk.x@example.com",
"password":"6187b9",
"display_name":"Ellie XUk X"
},{
"email":"robert.yuk.y@example.com",
"user_name": "robert.yuk.y@example.com",
"password":"e5046a",
"display_name":"Robert YUk Y"
},{
"email":"susan.yuk.y@example.com",
"user_name": "susan.yuk.y@example.com",
"password":"5b38a6",
"display_name":"Susan YUk Y"
},{
"email":"anil.yuk.y@example.com",
"user_name": "anil.yuk.y@example.com",
"password":"dcf03d",
"display_name":"Anil YUk Y"
},{
"email":"ellie.yuk.y@example.com",
"user_name": "ellie.yuk.y@example.com",
"password":"4f9eaa",
"display_name":"Ellie YUk Y"
}],
"users": [
{
"email": "robert.x.0.gh@example.com",
"password": "V8%Ktssl(L",
"user_name": "Robert.X.0.GH"
},
{
"email": "susan.x.0.gh@example.com",
"password": "naW9u3C%bh",
"user_name": "Susan.X.0.GH"
},
{
"email": "anil.x.0.gh@example.com",
"password": "9W0RIrX-6f",
"user_name": "Anil.X.0.GH"
},
{
"email": "ellie.x.0.gh@example.com",
"password": "rMf_OHM0dW",
"user_name": "Ellie.X.0.GH"
},
{
"email": "robert.y.9.gh@example.com",
"password": "%1Z43kzt2L",
"user_name": "Robert.Y.9.GH"
},
{
"email": "susan.y.9.gh@example.com",
"password": "oITehM!B2V",
"user_name": "Susan.Y.9.GH"
},
{
"email": "anil.y.9.gh@example.com",
"password": "TuKaNO8oI-",
"user_name": "Anil.Y.9.GH"
},
{
"email": "ellie.y.9.gh@example.com",
"password": "SkJDH+ds2_",
"user_name": "Ellie.Y.9.GH"
}
],
"accounts":[{
"id":"05237266-b334-4704-a087-5b460a2ecf04",
"bank":"psd201-bank-x--uk",

View File

@ -12,39 +12,48 @@
"logo":"https://static.openbankproject.com/images/sandbox/bank_y.png",
"website":"https://www.example.com"
}],
"users":[{
"email":"robert.x.0.gh@example.com",
"password":"X!d1edcafd",
"user_name":"Robert.X.0.GH"
},{
"email":"susan.x.0.gh@example.com",
"password":"X!90e4e3e4",
"user_name":"Susan.X.0.GH"
},{
"email":"anil.x.0.gh@example.com",
"password":"X!eb06b005",
"user_name":"Anil.X.0.GH"
},{
"email":"ellie.x.0.gh@example.com",
"password":"X!5bc94405",
"user_name":"Ellie.X.0.GH"
},{
"email":"robert.y.9.gh@example.com",
"password":"X!039941de",
"user_name":"Robert.Y.9.GH"
},{
"email":"susan.y.9.gh@example.com",
"password":"X!bb4efa3d",
"user_name":"Susan.Y.9.GH"
},{
"email":"anil.y.9.gh@example.com",
"password":"X!098915cd",
"user_name":"Anil.Y.9.GH"
},{
"email":"ellie.y.9.gh@example.com",
"password":"X!6170b37b",
"user_name":"Ellie.Y.9.GH"
}],
"users": [
{
"email": "robert.x.0.gh@example.com",
"password": "V8%Ktssl(L",
"user_name": "Robert.X.0.GH"
},
{
"email": "susan.x.0.gh@example.com",
"password": "naW9u3C%bh",
"user_name": "Susan.X.0.GH"
},
{
"email": "anil.x.0.gh@example.com",
"password": "9W0RIrX-6f",
"user_name": "Anil.X.0.GH"
},
{
"email": "ellie.x.0.gh@example.com",
"password": "rMf_OHM0dW",
"user_name": "Ellie.X.0.GH"
},
{
"email": "robert.y.9.gh@example.com",
"password": "%1Z43kzt2L",
"user_name": "Robert.Y.9.GH"
},
{
"email": "susan.y.9.gh@example.com",
"password": "oITehM!B2V",
"user_name": "Susan.Y.9.GH"
},
{
"email": "anil.y.9.gh@example.com",
"password": "TuKaNO8oI-",
"user_name": "Anil.Y.9.GH"
},
{
"email": "ellie.y.9.gh@example.com",
"password": "SkJDH+ds2_",
"user_name": "Ellie.Y.9.GH"
}
],
"accounts":[{
"id":"f65e28a5-9abe-428f-85bb-6c3c38122adb",
"bank":"obp-bank-x-gh",

View File

@ -784,16 +784,24 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
* (?=.*\d) //should contain at least one digit
* (?=.*[a-z]) //should contain at least one lower case
* (?=.*[A-Z]) //should contain at least one upper case
* (?=.*[!"#$%&'\(\)*+,-./:;<=>?@\\[\\\\]^_\\`{|}~]) //should contain at least one special character
* ([A-Za-z0-9!"#$%&'\(\)*+,-./:;<=>?@\\[\\\\]^_\\`{|}~]{10,16}) //should contain 10 to 16 valid characters
* (?=.*[!\"#$%&'\(\)*+,-./:;<=>?@\\[\\\\]^_\\`{|}~\[\]]) //should contain at least one special character
* ([A-Za-z0-9!\"#$%&'\(\)*+,-./:;<=>?@\\[\\\\]^_\\`{|}~\[\]]{10,16}) //should contain 10 to 16 valid characters
**/
val regex =
"""^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#$%&'\(\)*+,-./:;<=>?@\\[\\\\]^_\\`{|}~])([A-Za-z0-9!"#$%&'\(\)*+,-./:;<=>?@\\[\\\\]^_\\`{|}~]{10,16})$""".r
password match {
case password if(password.length > 16 && password.length <= 512 && basicPasswordValidation(password) ==SILENCE_IS_GOLDEN) => true
case regex(password) if(basicPasswordValidation(password) ==SILENCE_IS_GOLDEN) => true
case _ => false
"""^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!\"#$%&'\(\)*+,-./:;<=>?@\\[\\\\]^_\\`{|}~\[\]])([A-Za-z0-9!\"#$%&'\(\)*+,-./:;<=>?@\\[\\\\]^_\\`{|}~\[\]]{10,16})$""".r
// first check `basicPasswordValidation`
if (basicPasswordValidation(password) != SILENCE_IS_GOLDEN) {
return false
}
// 2nd: check the password length between 10 and 512
if (password.length > 16 && password.length <= 512) {
return true
}
// 3rd: check the regular expression
regex.pattern.matcher(password).matches()
}
/** only A-Z, a-z, 0-9,-,_,. =, & and max length <= 2048 */
@ -847,12 +855,17 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
/** also support space now */
def basicPasswordValidation(value:String): String ={
val valueLength = value.length
val regex = """^([A-Za-z0-9!"#$%&'\(\)*+,-./:;<=>?@\\[\\\\]^_\\`{|}~ ]+)$""".r
value match {
case regex(e) if(valueLength <= 512) => SILENCE_IS_GOLDEN
case regex(e) if(valueLength > 512) => ErrorMessages.InvalidValueLength
case _ => ErrorMessages.InvalidValueCharacters
val regex = """^([A-Za-z0-9!\"#$%&'\(\)*+,-./:;<=>?@\\[\\\\]^_\\`{|}~ \[\]]+)$""".r
if (!regex.pattern.matcher(value).matches()) {
return ErrorMessages.InvalidValueCharacters
}
if (valueLength > 512) {
return ErrorMessages.InvalidValueLength
}
SILENCE_IS_GOLDEN
}
/** only A-Z, a-z, 0-9, -, _, ., @, and max length <= 512 */

View File

@ -130,7 +130,12 @@ object BerlinGroupSigning {
val signatureHeaderValue = getHeaderValue(RequestHeader.Signature, requestHeaders)
val signature = parseSignatureHeader(signatureHeaderValue).getOrElse("signature", "NONE")
val isVerified = verifySignature(signingString, signature, certificatePem)
if (isVerified) forwardResult else (Failure(ErrorMessages.X509PublicKeyCannotVerify), forwardResult._2)
val isValidated = CertificateVerifier.validateCertificate(certificatePem)
(isVerified, isValidated) match {
case (true, true) => forwardResult
case (true, false) => (Failure(ErrorMessages.X509PublicKeyCannotBeValidated), forwardResult._2)
case (false, _) => (Failure(ErrorMessages.X509PublicKeyCannotVerify), forwardResult._2)
}
case Failure(msg, t, c) => (Failure(msg, t, c), forwardResult._2) // PEM certificate is not valid
case _ => (Failure(ErrorMessages.X509GeneralError), forwardResult._2) // PEM certificate cannot be validated
}

View File

@ -0,0 +1,153 @@
package code.api.util
import code.util.Helper.MdcLoggable
import java.io.{ByteArrayInputStream, FileInputStream}
import java.security.KeyStore
import java.security.cert._
import java.util.{Base64, Collections}
import javax.net.ssl.TrustManagerFactory
import scala.io.Source
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Success, Try}
object CertificateVerifier extends MdcLoggable {
/**
* Loads a trust store (`.p12` file) from a configured path.
*
* This function:
* - Reads the trust store password from the application properties (`truststore.path.tpp_signature`).
* - Uses Java's `KeyStore` class to load the certificates.
*
* @return An `Option[KeyStore]` containing the loaded trust store, or `None` if loading fails.
*/
private def loadTrustStore(): Option[KeyStore] = {
val trustStorePath = APIUtil.getPropsValue("truststore.path.tpp_signature")
.or(APIUtil.getPropsValue("truststore.path")).getOrElse("")
val trustStorePassword = APIUtil.getPropsValue("truststore.password.tpp_signature", "").toCharArray
Try {
val trustStore = KeyStore.getInstance("PKCS12")
val trustStoreInputStream = new FileInputStream(trustStorePath)
try {
trustStore.load(trustStoreInputStream, trustStorePassword)
} finally {
trustStoreInputStream.close()
}
trustStore
} match {
case Success(store) =>
logger.info(s"Loaded trust store from: $trustStorePath")
Some(store)
case Failure(e) =>
logger.info(s"Failed to load trust store: ${e.getMessage}")
None
}
}
/**
* Verifies an X.509 certificate against the loaded trust store.
*
* This function:
* - Parses the PEM certificate into an `X509Certificate` using `parsePemToX509Certificate`.
* - Loads the trust store using `loadTrustStore()`.
* - Extracts trusted root CAs from the trust store.
* - Creates PKIX validation parameters and disables revocation checking.
* - Validates the certificate using Java's `CertPathValidator`.
*
* @param pemCertificate The X.509 certificate in PEM format.
* @return `true` if the certificate is valid and trusted, otherwise `false`.
*/
def validateCertificate(pemCertificate: String): Boolean = {
Try {
val certificate = parsePemToX509Certificate(pemCertificate)
// Load trust store
val trustStore = loadTrustStore()
.getOrElse(throw new Exception("Trust store could not be loaded."))
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm)
trustManagerFactory.init(trustStore)
// Get trusted CAs from the trust store
val trustAnchors = trustStore.aliases().asScala
.filter(trustStore.isCertificateEntry)
.map(alias => trustStore.getCertificate(alias).asInstanceOf[X509Certificate])
.map(cert => new TrustAnchor(cert, null))
.toSet
.asJava // Convert Scala Set to Java Set
if (trustAnchors.isEmpty) throw new Exception("No trusted certificates found in trust store.")
// Set up PKIX parameters for validation
val pkixParams = new PKIXParameters(trustAnchors)
pkixParams.setRevocationEnabled(false) // Disable CRL checks
// Validate certificate chain
val certPath = CertificateFactory.getInstance("X.509").generateCertPath(Collections.singletonList(certificate))
val validator = CertPathValidator.getInstance("PKIX")
validator.validate(certPath, pkixParams)
logger.info("Certificate is valid and trusted.")
true
} match {
case Success(_) => true
case Failure(e: CertPathValidatorException) =>
logger.info(s"Certificate validation failed: ${e.getMessage}")
false
case Failure(e) =>
logger.info(s"Error: ${e.getMessage}")
false
}
}
/**
* Converts a PEM certificate (Base64-encoded) into an `X509Certificate` object.
*
* This function:
* - Removes the PEM header and footer (`-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`).
* - Decodes the Base64-encoded certificate data.
* - Generates and returns an `X509Certificate` object.
*
* @param pem The X.509 certificate in PEM format.
* @return The parsed `X509Certificate` object.
*/
private def parsePemToX509Certificate(pem: String): X509Certificate = {
val cleanedPem = pem.replaceAll("-----BEGIN CERTIFICATE-----", "")
.replaceAll("-----END CERTIFICATE-----", "")
.replaceAll("\\s", "")
val decoded = Base64.getDecoder.decode(cleanedPem)
val certFactory = CertificateFactory.getInstance("X.509")
certFactory.generateCertificate(new ByteArrayInputStream(decoded)).asInstanceOf[X509Certificate]
}
def loadPemCertificateFromFile(filePath: String): Option[String] = {
Try {
val source = Source.fromFile(filePath)
try source.getLines().mkString("\n") // Read entire file into a single string
finally source.close()
} match {
case Success(pem) => Some(pem)
case Failure(exception) =>
logger.error(s"Failed to load PEM certificate from file: ${exception.getMessage}")
None
}
}
def main(args: Array[String]): Unit = {
// change the following path if using this function to test on your localhost
val certificatePath = "/path/to/certificate.pem"
val pemCertificate = loadPemCertificateFromFile(certificatePath)
pemCertificate.foreach { pem =>
val isValid = validateCertificate(pem)
logger.info(s"Certificate verification result: $isValid")
}
loadTrustStore().foreach { trustStore =>
logger.info(s"Trust Store contains ${trustStore.size()} certificates.")
}
}
}

View File

@ -279,6 +279,7 @@ object ErrorMessages {
val X509ThereAreNoPsd2Roles = "OBP-20308: PEM Encoded Certificate does not contain PSD2 roles."
val X509CannotGetPublicKey = "OBP-20309: Public key cannot be found in the PEM Encoded Certificate."
val X509PublicKeyCannotVerify = "OBP-20310: Certificate's public key cannot be used to verify signed request."
val X509PublicKeyCannotBeValidated = "OBP-20312: Certificate's public key cannot be validated."
val X509RequestIsNotSigned = "OBP-20311: The Request is not signed."
// OpenID Connect

View File

@ -72,7 +72,9 @@ trait OBPDataImport extends MdcLoggable {
protected def dataOrFirstFailure[T](boxes : List[Box[T]]) = {
val firstFailure = boxes.collectFirst{case f: Failure => f}
firstFailure match {
case Some(f) => f
case Some(f) =>
logger.debug(f)
f
case None => Full(boxes.flatten) //no failures, so we can return the results
}
}

View File

@ -1,89 +1,302 @@
/**
Open Bank Project - API
Copyright (C) 2011-2019, TESOBE GmbH.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Email: contact@tesobe.com
TESOBE GmbH.
Osloer Strasse 16/17
Berlin 13359, Germany
This product includes software developed at
TESOBE (http://www.tesobe.com/)
*/
* Open Bank Project - API
* Copyright (C) 2011-2019, TESOBE GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* Email: contact@tesobe.com
* TESOBE GmbH.
* Osloer Strasse 16/17
* Berlin 13359, Germany
*
* This product includes software developed at
* TESOBE (http://www.tesobe.com/)
*/
package code.snippet
import code.accountholders.AccountHolders
import code.api.RequestHeader
import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{GetConsentResponseJson, createGetConsentResponseJson}
import code.api.util.{ConsentJWT, CustomJsonFormats, JwtUtil}
import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{ConsentAccessAccountsJson, ConsentAccessJson, GetConsentResponseJson, createGetConsentResponseJson}
import code.api.util.ErrorMessages.ConsentNotFound
import code.api.util._
import code.api.v3_1_0.APIMethods310
import code.api.v5_0_0.APIMethods500
import code.api.v5_1_0.APIMethods510
import code.consent.{ConsentStatus, Consents, MappedConsent}
import code.consumer.Consumers
import code.model.dataAccess.AuthUser
import code.model.dataAccess.{AuthUser, BankAccountRouting}
import code.util.Helper.{MdcLoggable, ObpS}
import com.openbankproject.commons.ExecutionContext.Implicits.global
import com.openbankproject.commons.model.BankIdAccountId
import net.liftweb.common.{Box, Failure, Full}
import net.liftweb.http.js.JsCmds
import net.liftweb.http.rest.RestHelper
import net.liftweb.http.{RequestVar, S, SHtml, SessionVar}
import net.liftweb.http.{S, SHtml, SessionVar}
import net.liftweb.json.{Formats, parse}
import net.liftweb.mapper.By
import net.liftweb.util.CssSel
import net.liftweb.util.Helpers._
import scala.collection.immutable
import scala.concurrent.Future
import scala.xml.NodeSeq
/**
* This class handles Berlin Group consent requests.
* It provides functionality to confirm or deny consent requests,
* and manages the consent process for accessing account data.
*/
class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510 with APIMethods500 with APIMethods310 {
// Custom JSON formats for serialization/deserialization
protected implicit override def formats: Formats = CustomJsonFormats.formats
private object otpValue extends SessionVar("123")
private object redirectUriValue extends SessionVar("")
// Session variables to store OTP, redirect URI, and other consent-related data
private object otpValue extends SessionVar("123") // Stores the OTP value for SCA (Strong Customer Authentication)
private object redirectUriValue extends SessionVar("") // Stores the redirect URI for post-consent actions
private object updateConsentPayloadValue extends SessionVar(false) // Flag to indicate if consent payload needs updating
private object userIsOwnerOfAccountsValue extends SessionVar(true) // Flag to check if the user owns the accounts
// Session variables to store selected IBANs for accounts, balances, and transactions
private object selectedAccountsIbansValue extends SessionVar[Set[String]](Set()) {
override def set(value: Set[String]): Set[String] = {
logger.debug(s"selectedAccountsIbansValue changed to: ${value.mkString(", ")}")
super.set(value)
}
}
private object accessAccountsDefinedVar extends SessionVar(true)
private object accessBalancesDefinedVar extends SessionVar(true)
private object accessTransactionsDefinedVar extends SessionVar(true)
/**
* Creates a ConsentAccessJson object from lists of IBANs for accounts, balances, and transactions.
*
* @param accounts List of IBANs for accounts.
* @param balances List of IBANs for balances.
* @param transactions List of IBANs for transactions.
* @return ConsentAccessJson object.
*/
def createConsentAccessJson(accounts: List[String], balances: List[String], transactions: List[String]): ConsentAccessJson = {
val accountsList = accounts.map(iban => ConsentAccessAccountsJson(iban = Some(iban), None, None, None, None, None))
val balancesList = balances.map(iban => ConsentAccessAccountsJson(iban = Some(iban), None, None, None, None, None))
val transactionsList = transactions.map(iban => ConsentAccessAccountsJson(iban = Some(iban), None, None, None, None, None))
ConsentAccessJson(
accounts = Some(accountsList), // Populate accounts
balances = if (balancesList.nonEmpty) Some(balancesList) else None, // Populate balances
transactions = if (transactionsList.nonEmpty) Some(transactionsList) else None // Populate transactions
)
}
/**
* Updates the consent with new IBANs for accounts, balances, and transactions.
*
* @param consentId The ID of the consent to update.
* @param ibans List of IBANs for accounts.
* @return Future[MappedConsent] representing the updated consent.
*/
private def updateConsent(consentId: String, ibans: List[String], canReadBalances: Boolean, canReadTransactions: Boolean): Future[MappedConsent] = {
for {
// Fetch the consent by ID
consent: MappedConsent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map {
APIUtil.unboxFullOrFail(_, None, s"$ConsentNotFound ($consentId)", 404)
}
// Update the consent JWT with new access details
consentJWT <- Consent.updateAccountAccessOfBerlinGroupConsentJWT(
createConsentAccessJson(
ibans,
if(canReadBalances) ibans else List(),
if(canReadTransactions) ibans else List()
),
consent,
None
) map {
i => APIUtil.connectorEmptyResponse(i, None)
}
// Save the updated consent
updatedConsent <- Future(Consents.consentProvider.vend.setJsonWebToken(consent.consentId, consentJWT)) map {
i => APIUtil.connectorEmptyResponse(i, None)
}
} yield {
updatedConsent
}
}
/**
* Renders the Berlin Group consent confirmation form.
*
* @return CssSel for rendering the form.
*/
def confirmBerlinGroupConsentRequest: CssSel = {
callGetConsentByConsentId() match {
case Full(consent) =>
// Set OTP and redirect URI from the consent
otpValue.set(consent.challenge)
val json: GetConsentResponseJson = createGetConsentResponseJson(consent)
val consumer = Consumers.consumers.vend.getConsumerByConsumerId(consent.consumerId)
val consentJwt: Box[ConsentJWT] = JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map(parse(_)
.extract[ConsentJWT])
val tppRedirectUri: immutable.Seq[String] = consentJwt.map{ h =>
val tppRedirectUri: immutable.Seq[String] = consentJwt.map { h =>
h.request_headers.filter(h => h.name == RequestHeader.`TPP-Redirect-URL`)
}.getOrElse(Nil).map((_.values.mkString("")))
val consumerRedirectUri: Option[String] = consumer.map(_.redirectURL.get).toOption
val uri: String = tppRedirectUri.headOption.orElse(consumerRedirectUri).getOrElse("https://not.defined.com")
redirectUriValue.set(uri)
// Get all accounts held by the current user
val userAccounts: Set[BankIdAccountId] =
AccountHolders.accountHolders.vend.getAccountsHeldByUser(AuthUser.currentUser.flatMap(_.user.foreign).openOrThrowException(ErrorMessages.UserNotLoggedIn), Some(null)).toSet
val userIbans: Set[String] = userAccounts.flatMap { acc =>
BankAccountRouting.find(
By(BankAccountRouting.BankId, acc.bankId.value),
By(BankAccountRouting.AccountId, acc.accountId.value),
By(BankAccountRouting.AccountRoutingScheme, "IBAN")
).map(_.AccountRoutingAddress.get)
}
// Select all IBANs
selectedAccountsIbansValue.set(userIbans)
// 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
updateConsentPayloadValue.set(true)
accessAccountsDefinedVar.set(true)
userIbans.toList
case Some(accounts) if accounts.flatMap(_.iban).toSet.subsetOf(userIbans) => // Access is requested for specific IBANs
accessAccountsDefinedVar.set(true)
accounts.flatMap(_.iban)
case Some(accounts) => // Logged in user is not an owner of IBAN/IBANs
userIsOwnerOfAccountsValue.set(false)
accessAccountsDefinedVar.set(true)
accounts.flatMap(_.iban)
case None => // Access is not requested
accessAccountsDefinedVar.set(false)
List()
}
val canReadBalancesIbans: List[String] = json.access.balances match {
case Some(balances) if balances.isEmpty => // Access is requested
updateConsentPayloadValue.set(true)
accessBalancesDefinedVar.set(true)
userIbans.toList
case Some(balances) if balances.flatMap(_.iban).toSet.subsetOf(userIbans) => // Access is requested for specific IBANs
accessBalancesDefinedVar.set(true)
balances.flatMap(_.iban)
case Some(balances) => // Logged in user is not an owner of IBAN/IBANs
userIsOwnerOfAccountsValue.set(false)
accessBalancesDefinedVar.set(true)
balances.flatMap(_.iban)
case None => // Access is not requested
accessBalancesDefinedVar.set(false)
List()
}
val canReadTransactionsIbans: List[String] = json.access.transactions match {
case Some(transactions) if transactions.isEmpty => // Access is requested
updateConsentPayloadValue.set(true)
accessTransactionsDefinedVar.set(true)
userIbans.toList
case Some(transactions) if transactions.flatMap(_.iban).toSet.subsetOf(userIbans) => // Access is requested for specific IBANs
accessTransactionsDefinedVar.set(true)
transactions.flatMap(_.iban)
case Some(transactions) => // Logged in user is not an owner of IBAN/IBANs
userIsOwnerOfAccountsValue.set(false)
accessTransactionsDefinedVar.set(true)
transactions.flatMap(_.iban)
case None => // Access is not requested
accessTransactionsDefinedVar.set(false)
List()
}
/**
* Generates toggle switches for IBAN lists.
*
* @param scope The scope of the IBANs (e.g., "canReadAccountsIbans").
* @param ibans List of IBANs to display.
* @param selectedList Set of currently selected IBANs.
* @param sessionVar Session variable to update when toggling.
* @return Sequence of NodeSeq representing the toggle switches.
*/
def generateCheckboxes(scope: String, ibans: List[String], selectedList: Set[String], sessionVar: SessionVar[Set[String]]): immutable.Seq[NodeSeq] = {
ibans.map { iban =>
if (updateConsentPayloadValue.is) {
// Show toggle switch when updateConsentPayloadValue is true
<div class="toggle-container">
<label class="switch">
{SHtml.ajaxCheckbox(selectedList.contains(iban), checked => {
if (checked) {
sessionVar.set(selectedList + iban) // Add to selected
} else {
sessionVar.set(selectedList - iban) // Remove from selected
}
JsCmds.Noop // Prevents page reload
}, "id" -> (iban + scope), "class" -> "toggle-input")}<span class="slider round"></span>
</label>
<span style="all: unset;" class="toggle-label">
{iban}
</span>
</div>
} else {
// Show only the IBAN text when updateConsentPayloadValue is false
<span style="all: unset;" class="toggle-label">
{iban}
</span>
}
}
}
// Form text and user details
val currentUser = AuthUser.currentUser
val firstName = currentUser.map(_.firstName.get).getOrElse("")
val lastName = currentUser.map(_.lastName.get).getOrElse("")
val consumerName = consumer.map(_.name.get).getOrElse("")
val formText =
s"""I, ${AuthUser.currentUser.map(_.firstName.get).getOrElse("")} ${AuthUser.currentUser.map(_.lastName.get).getOrElse("")}, consent to the service provider ${consumer.map(_.name.get).getOrElse("")} making actions on my behalf.
|
|This consent must respects the following actions:
|
| 1) Can read accounts: ${json.access.accounts.getOrElse(Nil).flatMap(_.iban).mkString(", ")}
| 2) Can read balances: ${json.access.balances.getOrElse(Nil).flatMap(_.iban).mkString(", ")}
| 3) Can read transactions: ${json.access.transactions.getOrElse(Nil).flatMap(_.iban).mkString(", ")}
|
|This consent will end on date ${json.validUntil}.
|
|I understand that I can revoke this consent at any time.
|""".stripMargin
s"""I, $firstName $lastName, consent to the service provider <strong>$consumerName</strong> making the following actions on my behalf:
|""".stripMargin
// Converting formText into a NodeSeq for raw HTML
val formTextHtml: NodeSeq = scala.xml.XML.loadString("<div>" + formText + "</div>")
"#confirm-bg-consent-request-form-title *" #> s"Please confirm or deny the following consent request:" &
"#confirm-bg-consent-request-form-text *" #> s"""$formText""" &
// Form rendering
"#confirm-bg-consent-request-form-title *" #> "Please confirm or deny the following consent request:" &
"#confirm-bg-consent-request-form-text *" #> (
<div>
<p>
{formTextHtml}
</p>
<div>
<p><strong>Allowed actions:</strong></p>
<p style="padding-left: 20px">Read account details</p>
<p style={if (accessBalancesDefinedVar.is) "padding-left: 20px;" else "padding-left: 20px; display: none;"}>Read account balances</p>
<p style={if (accessTransactionsDefinedVar.is) "padding-left: 20px;" else "padding-left: 20px; display: none;"}>Read transactions</p>
</div>
<div>
<p><strong>Accounts</strong>:</p>
<div style="padding-left: 20px">
{generateCheckboxes("canReadAccountsIbans", userIbans.toList, selectedAccountsIbansValue.is, selectedAccountsIbansValue)}
</div>
<br/>
</div>
<p>This consent will end on date {json.validUntil}.</p>
<p>I understand that I can revoke this consent at any time.</p>
</div>
) & {
if (userIsOwnerOfAccountsValue) {
"#confirm-bg-consent-request-confirm-submit-button" #> SHtml.onSubmitUnit(confirmConsentRequestProcess) &
"#confirm-bg-consent-request-deny-submit-button" #> SHtml.onSubmitUnit(denyConsentRequestProcess)
"#confirm-bg-consent-request-deny-submit-button" #> SHtml.onSubmitUnit(denyConsentRequestProcess)
} else {
S.error(s"User $firstName $lastName is not owner of listed accounts")
"#confirm-bg-consent-request-confirm-submit-button" #> "" &
"#confirm-bg-consent-request-deny-submit-button" #> ""
}}
case everythingElse =>
S.error(everythingElse.toString)
"#confirm-bg-consent-request-form-title *" #> s"Please confirm or deny the following consent request:" &
@ -91,6 +304,11 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
}
}
/**
* Fetches a consent by its ID.
*
* @return Box[MappedConsent] containing the consent if found.
*/
private def callGetConsentByConsentId(): Box[MappedConsent] = {
val requestParam = List(
ObpS.param("CONSENT_ID"),
@ -103,12 +321,31 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
}
}
/**
* Handles the confirmation of a consent request.
*/
private def confirmConsentRequestProcess() = {
val consentId = ObpS.param("CONSENT_ID") openOr ("")
S.redirectTo(
s"/confirm-bg-consent-request-sca?CONSENT_ID=${consentId}"
)
if (selectedAccountsIbansValue.is.isEmpty) {
S.error(s"Please select at least 1 account")
} else {
val consentId = ObpS.param("CONSENT_ID") openOr ("")
if (updateConsentPayloadValue.is) {
updateConsent(
consentId,
selectedAccountsIbansValue.is.toList,
accessBalancesDefinedVar.is,
accessTransactionsDefinedVar.is
)
}
S.redirectTo(
s"/confirm-bg-consent-request-sca?CONSENT_ID=${consentId}"
)
}
}
/**
* Handles the denial of a consent request.
*/
private def denyConsentRequestProcess() = {
val consentId = ObpS.param("CONSENT_ID") openOr ("")
Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.rejected)
@ -116,6 +353,10 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
s"$redirectUriValue?CONSENT_ID=${consentId}"
)
}
/**
* Handles the confirmation of a consent request with SCA (Strong Customer Authentication).
*/
private def confirmConsentRequestProcessSca() = {
val consentId = ObpS.param("CONSENT_ID") openOr ("")
Consents.consentProvider.vend.getConsentByConsentId(consentId) match {
@ -129,7 +370,11 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
}
}
/**
* Renders the SCA confirmation form for Berlin Group consent.
*
* @return CssSel for rendering the form.
*/
def confirmBgConsentRequest: CssSel = {
"#otp-value" #> SHtml.text(otpValue, otpValue(_)) &
"type=submit" #> SHtml.onSubmitUnit(confirmConsentRequestProcessSca)

View File

@ -32,7 +32,7 @@ Berlin 13359, Germany
<div class="form-group">
<h3 id="confirm-bg-consent-request-form-title">Please check the Berlin Group Consent Request: </h3>
<div id="confirm-bg-consent-request-form-text-div">
<pre id="confirm-bg-consent-request-form-text"></pre>
<div id="confirm-bg-consent-request-form-text"></div>
</div>
</div>
<form method="post">

View File

@ -37,6 +37,7 @@ import code.api.util._
import code.setup.PropsReset
import code.util.Helper.SILENCE_IS_GOLDEN
import com.openbankproject.commons.model.UserAuthContextCommons
import com.github.dwickern.macros.NameOf.nameOf
import net.liftweb.common.{Box, Empty, Full}
import net.liftweb.http.provider.HTTPParam
import net.liftweb.json.{JValue, parse}
@ -838,6 +839,65 @@ class APIUtilTest extends FeatureSpec with Matchers with GivenWhenThen with Prop
actualValue6 contains (InvalidValueLength) shouldBe (true)
}
scenario(s"Test the ${nameOf(APIUtil.basicPasswordValidation _)} method") {
val firefoxStrongPasswordProposal = "9YF]gZnXzAENM+]"
basicPasswordValidation(firefoxStrongPasswordProposal) shouldBe (SILENCE_IS_GOLDEN) // SILENCE_IS_GOLDEN
basicPasswordValidation("Abc!123 xyz") shouldBe (SILENCE_IS_GOLDEN) // SILENCE_IS_GOLDEN
basicPasswordValidation("SuperStrong#123") shouldBe (SILENCE_IS_GOLDEN) // SILENCE_IS_GOLDEN
basicPasswordValidation("Hello World!") shouldBe (SILENCE_IS_GOLDEN) // SILENCE_IS_GOLDEN
basicPasswordValidation(" ") shouldBe (SILENCE_IS_GOLDEN) // SILENCE_IS_GOLDEN allow space so far
basicPasswordValidation("short💥") shouldBe (InvalidValueCharacters) // ErrorMessages.InvalidValueCharacters
basicPasswordValidation("a" * 513) shouldBe (InvalidValueLength) // ErrorMessages.InvalidValueLength
}
scenario(s"Test the ${nameOf(APIUtil.fullPasswordValidation _)} method") {
val firefoxStrongPasswordProposal = "9YF]gZnXzAENM+]"
fullPasswordValidation(firefoxStrongPasswordProposal) // true
fullPasswordValidation("Abc!123xyz") // true
fullPasswordValidation("SuperStrong#123") // true
fullPasswordValidation("Abcdefg!1") // true
fullPasswordValidation("short1!") // falsetoo short
fullPasswordValidation("alllowercase123!") // falseno capital letter
fullPasswordValidation("ALLUPPERCASE123!") // falseno smaller case letter
fullPasswordValidation("NoSpecialChar123") // falsenot special character
}
}
feature(s"test ${nameOf(APIUtil.basicPasswordValidation _)} and ${nameOf(APIUtil.fullPasswordValidation _)}") {
scenario(s"Test the ${nameOf(APIUtil.basicPasswordValidation _)} method") {
val firefoxStrongPasswordProposal = "9YF]gZnXzAENM+]"
basicPasswordValidation(firefoxStrongPasswordProposal) shouldBe (SILENCE_IS_GOLDEN) // SILENCE_IS_GOLDEN
basicPasswordValidation("Abc!123 xyz") shouldBe (SILENCE_IS_GOLDEN) // SILENCE_IS_GOLDEN
basicPasswordValidation("SuperStrong#123") shouldBe (SILENCE_IS_GOLDEN) // SILENCE_IS_GOLDEN
basicPasswordValidation("Hello World!") shouldBe (SILENCE_IS_GOLDEN) // SILENCE_IS_GOLDEN
basicPasswordValidation(" ") shouldBe (SILENCE_IS_GOLDEN) // SILENCE_IS_GOLDEN allow space so far
basicPasswordValidation("short💥") shouldBe (InvalidValueCharacters) // ErrorMessages.InvalidValueCharacters
basicPasswordValidation("a" * 513) shouldBe (InvalidValueLength) // ErrorMessages.InvalidValueLength
}
scenario(s"Test the ${nameOf(APIUtil.fullPasswordValidation _)} method") {
val firefoxStrongPasswordProposal = "9YF]gZnXzAENM+]"
fullPasswordValidation(firefoxStrongPasswordProposal) // true
fullPasswordValidation("Abc!123xyz") // true
fullPasswordValidation("SuperStrong#123") // true
fullPasswordValidation("Abcdefg!1") // true
fullPasswordValidation("short1!") // falsetoo short
fullPasswordValidation("alllowercase123!") // falseno capital letter
fullPasswordValidation("ALLUPPERCASE123!") // falseno smaller case letter
fullPasswordValidation("NoSpecialChar123") // falsenot special character
}
}