Skip to content

Commit

Permalink
Merge pull request #2349 from constantine2nd/develop
Browse files Browse the repository at this point in the history
Dynamic Registration of Consumer
  • Loading branch information
simonredfern authored Dec 6, 2023
2 parents 03a4f39 + aed7aef commit b6de507
Show file tree
Hide file tree
Showing 8 changed files with 196 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2667,6 +2667,31 @@ object SwaggerDefinitionsJSON {
enabled = true,
created = DateWithDayExampleObject
)
lazy val pem = "-----BEGIN CERTIFICATE-----\nMIIFIjCCBAqgAwIBAgIIX3qsz7QQxngwDQYJKoZIhvcNAQELBQAwgZ8xCzAJBgNV\r\nBAYTAkRFMQ8wDQYDVQQIEwZCZXJsaW4xDzANBgNVBAcTBkJlcmxpbjEPMA0GA1UE\r\nChMGVEVTT0JFMRowGAYDVQQLExFURVNPQkUgT3BlcmF0aW9uczESMBAGA1UEAxMJ\r\nVEVTT0JFIENBMR8wHQYJKoZIhvcNAQkBFhBhZG1pbkB0ZXNvYmUuY29tMQwwCgYD\r\nVQQpEwNWUE4wHhcNMjMwNzE3MDg0MDAwWhcNMjQwNzE3MDg0MDAwWjCBizELMAkG\r\nA1UEBhMCREUxDzANBgNVBAgTBkJlcmxpbjEPMA0GA1UEBxMGQmVybGluMRQwEgYD\r\nVQQKEwtUZXNvYmUgR21iSDEPMA0GA1UECxMGc3lzb3BzMRIwEAYDVQQDEwlsb2Nh\r\nbGhvc3QxHzAdBgkqhkiG9w0BCQEWEGFkbWluQHRlc29iZS5jb20wggEiMA0GCSqG\r\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCwxGuWUN1H0d0IeYPYWdLA0I/5BXx4DLO6\r\nzfi1GGJlF8BIXRN0VTJckIY9C3J1RnXDs6p6ufA01iHe1PQdL6VzfcaC3j+jUSgV\r\n1z9ybEUPyUwq3PCCxqoVI9n8yh+O6FDn3dvu/9Q2NtBpJHUBDCLf7OO9TgsFU2sE\r\nMys+Hw5DuuX5n5OQ2VIwH+qlMTQnd+yw5y8FKHqAZT5hE60lF/x6sQnwi58hLGRW\r\nSqo/548c2ZpoeWtnyY1I6PyR7zUYGuhruLY8gVFfLE+610u/lj2wYTXMxntpV+tV\r\nralLFRMhvbqZXW/EpuDb/pEbCnLDNDxq5NarLVDzcHs7VhT9MPChAgMBAAGjggFy\r\nMIIBbjATBgNVHSUEDDAKBggrBgEFBQcDAjAaBgNVHREEEzARgglsb2NhbGhvc3SH\r\nBH8AAAEwggEGBggrBgEFBQcBAwSB+TCB9jAIBgYEAI5GAQEwOAYGBACORgEFMC4w\r\nLBYhaHR0cHM6Ly9leGFtcGxlLm9yZy9wa2lkaXNjbG9zdXJlEwdleGFtcGxlMIGI\r\nBgYEAIGYJwIwfjBMMBEGBwQAgZgnAQMMBlBTUF9BSTARBgcEAIGYJwEBDAZQU1Bf\r\nQVMwEQYHBACBmCcBAgwGUFNQX1BJMBEGBwQAgZgnAQQMBlBTUF9JQwwlRHVtbXkg\r\nRmluYW5jaWFsIFN1cGVydmlzaW9uIEF1dGhvcml0eQwHWFgtREZTQTAlBgYEAI5G\r\nAQYwGwYHBACORgEGAQYHBACORgEGAgYHBACORgEGAzARBglghkgBhvhCAQEEBAMC\r\nB4AwHgYJYIZIAYb4QgENBBEWD3hjYSBjZXJ0aWZpY2F0ZTANBgkqhkiG9w0BAQsF\r\nAAOCAQEAKTS7exS9A7rWJLRzWrlHoTu68Avm5g9Dz1GKjgt8rnvj3D21SE14Rf5p\r\n0JWHYH4SiCdnh8Tx+IA7o0TmPJ1JRfAXR3i/5R7TJi/HrnqL+V7SIx2Cuq/hkZEU\r\nAhVs07nnvHURcrlQGwcfn4TbgpCURpCPpYZlNsYySb6BS6I4qFaadHGqMTyEkphV\r\nwfXyB3brmzxj9V4Qgp0t+s/uFuFirWyIayRc9nSSC7vuNVYvib2Kim4y8kvuWpA4\r\nZ51+fFOmBqCqpmwfAADNgDsLJiA/741eBflVd/ZUeAzgOjMCMIaDGlwiwZlePKT7\r\n553GtfsGxZMf05oqfUrQEQfJaU+/+Q==\n-----END CERTIFICATE-----\n"
lazy val certificateInfoJsonV510 = CertificateInfoJsonV510(
subject_domain_name = "OID.2.5.4.41=VPN, [email protected], CN=TESOBE CA, OU=TESOBE Operations, O=TESOBE, L=Berlin, ST=Berlin, C=DE",
issuer_domain_name = "CN=localhost, O=TESOBE GmbH, ST=Berlin, C=DE",
not_before = "2022-04-01T10:13:00.000Z",
not_after = "2032-04-01T10:13:00.000Z",
roles = None,
roles_info = Some("PEM Encoded Certificate does not contain PSD2 roles.")
)
lazy val consumerJsonV510: ConsumerJsonV510 = ConsumerJsonV510(
consumer_id = "d0d7b08c-f0ec-4e57-ac99-7d9eafe99225",
consumer_key = "d0d7b08c-f0ec-4e57-ac99-7d9eafe99225",
consumer_secret = "d0d7b08c-f0ec-4e57-ac99-7d9eafe99225",
app_name = "SOFI",
app_type = "Web",
description = "Account Management",
developer_email = ExampleValue.emailExample.value,
company = ExampleValue.companyExample.value,
redirect_url = "www.openbankproject.com",
certificate_pem = pem,
certificate_info = Some(certificateInfoJsonV510),
created_by_user = resourceUserJSON,
enabled = true,
created = DateWithDayExampleObject
)

val consumersJson = ConsumersJson(
list = List(consumerJSON)
Expand Down Expand Up @@ -4200,15 +4225,6 @@ object SwaggerDefinitionsJSON {
val oAuth2ServerJWKURIJson = OAuth2ServerJWKURIJson("https://www.googleapis.com/oauth2/v3/certs")

val oAuth2ServerJwksUrisJson = OAuth2ServerJwksUrisJson(List(oAuth2ServerJWKURIJson))

val certificateInfoJsonV510 = CertificateInfoJsonV510(
subject_domain_name = "OID.2.5.4.41=VPN, [email protected], CN=TESOBE CA, OU=TESOBE Operations, O=TESOBE, L=Berlin, ST=Berlin, C=DE",
issuer_domain_name = "CN=localhost, O=TESOBE GmbH, ST=Berlin, C=DE",
not_before = "2022-04-01T10:13:00.000Z",
not_after = "2032-04-01T10:13:00.000Z",
roles = None,
roles_info = Some("PEM Encoded Certificate does not contain PSD2 roles.")
)

val updateAccountRequestJsonV310 = UpdateAccountRequestJsonV310(
label = "Label",
Expand Down
2 changes: 2 additions & 0 deletions obp-api/src/main/scala/code/api/util/ErrorMessages.scala
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,8 @@ object ErrorMessages {

val RegulatedEntityNotFound = "OBP-34100: Regulated Entity not found. Please specify a valid value for REGULATED_ENTITY_ID."
val RegulatedEntityNotDeleted = "OBP-34101: Regulated Entity cannot be deleted. Please specify a valid value for REGULATED_ENTITY_ID."
val RegulatedEntityNotFoundByCertificate = "OBP-34102: Regulated Entity cannot be found by provided certificate."
val PostJsonIsNotSigned = "OBP-34110: JWT at the post json cannot be verified."

// Consents
val ConsentNotFound = "OBP-35001: Consent not found by CONSENT_ID. "
Expand Down
9 changes: 9 additions & 0 deletions obp-api/src/main/scala/code/api/util/JwtUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,15 @@ object JwtUtil extends MdcLoggable {
jwk.toPublicJWK.toRSAKey
}

def verifyJwt(jwtString: String, pemEncodedRsaPublicKey: String): Boolean = {
// Parse PEM-encoded key to RSA public / private JWK
val jwk: JWK = JWK.parseFromPEMEncodedObjects(pemEncodedRsaPublicKey);
val rsaPublicKey: RSAKey = jwk.toPublicJWK.toRSAKey
val signedJWT = SignedJWT.parse(jwtString)
val verifier = new RSASSAVerifier(rsaPublicKey)
signedJWT.verify(verifier)
}


def main(args: Array[String]): Unit = {
val jwtToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjhhYWQ2NmJkZWZjMWI0M2Q4ZGIyN2U2NWUyZTJlZjMwMTg3OWQzZTgiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJhdF9oYXNoIjoiWGlpckZ1cnJ2X0ZxN3RHd25rLWt1QSIsIm5hbWUiOiJNYXJrbyBNaWxpxIciLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDUuZ29vZ2xldXNlcmNvbnRlbnQuY29tLy1YZDQ0aG5KNlREby9BQUFBQUFBQUFBSS9BQUFBQUFBQUFBQS9BS3hyd2NhZHd6aG00TjR0V2s1RThBdnhpLVpLNmtzNHFnL3M5Ni1jL3Bob3RvLmpwZyIsImdpdmVuX25hbWUiOiJNYXJrbyIsImZhbWlseV9uYW1lIjoiTWlsacSHIiwibG9jYWxlIjoiZW4iLCJpYXQiOjE1NDczMTE3NjAsImV4cCI6MTU0NzMxNTM2MH0.UyOmM0rsO0-G_ibDH3DFogS94GcsNd9GtYVw7j3vSMjO1rZdIraV-N2HUtQN3yHopwdf35A2FEJaag6X8dbvEkJC7_GAynyLIpodoaHNtaLbww6XQSYuQYyF27aPMpROoGZUYkMpB_82LF3PbD4ecDPC2IA5oSyDF4Eya4yn-MzxYmXS7usVWvanREg8iNQSxpu7zZqj4UwhvSIv7wH0vskr_M-PnefQzNTrdUx74i-v9lVqC4E_bF5jWeDGO8k5dqWqg55QuZdyJdSh89KNiIjJXGZDWUBzGfsbetWRnObIgX264fuOW4SpRglUc8fzv41Sc7SSqjqRAFm05t60kg"
Expand Down
36 changes: 34 additions & 2 deletions obp-api/src/main/scala/code/api/util/X509.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import code.util.Helper.MdcLoggable
import com.github.dwickern.macros.NameOf
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jose.util.X509CertUtils
import net.liftweb.common.{Box, Failure, Full}
import net.liftweb.common.{Box, Failure, Full, Empty}
import org.bouncycastle.asn1._
import org.bouncycastle.asn1.x509.Extension
import org.bouncycastle.asn1.x509.qualified.QCStatement
Expand Down Expand Up @@ -246,5 +246,37 @@ object X509 extends MdcLoggable {
case None => Failure(ErrorMessages.X509CannotGetCertificate)
}
}


def getCommonName(pem: Option[String]): Box[String] = {
getFieldCommon(pem, "CN")
}
def getOrganization(pem: Option[String]): Box[String] = {
getFieldCommon(pem, "O")
}
def getOrganizationUnit(pem: Option[String]): Box[String] = {
getFieldCommon(pem, "OU")
}
def getEmailAddress(pem: Option[String]): Box[String] = {
getFieldCommon(pem, "EMAILADDRESS")
.or(getFieldCommon(pem, "EMAILADDRESS".toLowerCase()))
}

private def getFieldCommon(pem: Option[String], field: String) = {
pem match {
case Some(unboxedPem) =>
extractCertificateInfo(unboxedPem).map { item =>
val splitByComma: Array[String] = item.subject_domain_name.split(",")
val splitByKeyValuePair: Array[(String, String)] = splitByComma.map(i => i.split("=")(0).trim -> i.split("=")(1).trim)
val valuesAsMap: Map[String, List[String]] = splitByKeyValuePair.toList.groupBy(_._1).map { case (k, v) => (k, v.map(_._2)) }
val result: String = valuesAsMap.get(field).map(_.mkString).getOrElse("")
result
} match {
case Full(value) if value.isEmpty => Empty
case everythingElse => everythingElse
}
case _ =>
Empty
}
}

}
15 changes: 9 additions & 6 deletions obp-api/src/main/scala/code/api/util/newstyle/Consumer.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package code.api.util.newstyle

import code.api.util.APIUtil.{OBPReturnType, unboxFull}
import code.api.util.APIUtil.{OBPReturnType, unboxFull, unboxFullOrFail}
import code.api.util.CallContext
import code.api.util.ErrorMessages.CreateConsumerError
import code.consumer.Consumers
import code.model.{AppType, Consumer}

Expand All @@ -18,6 +19,7 @@ object Consumer {
appType: Option[AppType],
description: Option[String],
developerEmail: Option[String],
company: Option[String],
redirectURL: Option[String],
createdByUserId: Option[String],
clientCertificate: Option[String],
Expand All @@ -33,12 +35,13 @@ object Consumer {
developerEmail,
redirectURL,
createdByUserId,
clientCertificate
) map {
(_, callContext)
}
clientCertificate,
company
)
} map {
unboxFull(_)
(_, callContext)
} map {
x => (unboxFullOrFail(x._1, callContext, CreateConsumerError, 400), x._2)
}
}

Expand Down
1 change: 1 addition & 0 deletions obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5295,6 +5295,7 @@ trait APIMethods400 extends MdcLoggable {
appType = None,
description = Some(postedJson.description),
developerEmail = Some(postedJson.developer_email),
company = None,
redirectURL = Some(postedJson.redirect_url),
createdByUserId = Some(u.userId),
clientCertificate = Some(postedJson.clientCertificate),
Expand Down
96 changes: 95 additions & 1 deletion obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import code.api.util.ApiRole._
import code.api.util.ApiTag._
import code.api.util.ErrorMessages.{$UserNotLoggedIn, BankNotFound, ConsentNotFound, InvalidJsonFormat, UnknownError, UserNotFoundByUserId, UserNotLoggedIn, _}
import code.api.util.FutureUtil.{EndpointContext, EndpointTimeout}
import code.api.util.JwtUtil.{getSignedPayloadAsJson, verifyJwt}
import code.api.util.NewStyle.HttpCode
import code.api.util.X509.{getCommonName, getEmailAddress, getOrganization}
import code.api.util._
import code.api.util.newstyle.Consumer.createConsumerNewStyle
import code.api.util.newstyle.RegulatedEntityNewStyle.{createRegulatedEntityNewStyle, deleteRegulatedEntityNewStyle, getRegulatedEntitiesNewStyle, getRegulatedEntityByEntityIdNewStyle}
import code.api.v2_1_0.{ConsumerRedirectUrlJSON, JSONFactory210}
import code.api.v3_0_0.JSONFactory300
Expand All @@ -23,6 +26,7 @@ import code.bankconnectors.Connector
import code.consent.Consents
import code.loginattempts.LoginAttempt
import code.metrics.APIMetrics
import code.model.AppType
import code.model.dataAccess.MappedBankAccount
import code.regulatedentities.MappedRegulatedEntityProvider
import code.transactionrequests.TransactionRequests.TransactionRequestTypes.{apply => _}
Expand All @@ -39,8 +43,9 @@ import com.openbankproject.commons.model._
import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion}
import net.liftweb.common.Full
import net.liftweb.http.rest.RestHelper
import net.liftweb.json.parse
import net.liftweb.json.{compactRender, parse}
import net.liftweb.mapper.By
import net.liftweb.util.Helpers
import net.liftweb.util.Helpers.tryo

import scala.collection.immutable.{List, Nil}
Expand Down Expand Up @@ -1772,6 +1777,93 @@ trait APIMethods510 {
}


staticResourceDocs += ResourceDoc(
createConsumer,
implementedInApiVersion,
"createConsumer",
"POST",
"/dynamic-registration/consumers",
"Create a Consumer",
s"""Create a Consumer (mTLS access).
|
| JWT payload:
| - minimal
| { "description":"Description" }
| - full
| {
| "description": "Description",
| "app_name": "Tesobe GmbH",
| "app_type": "Sofit",
| "developer_email": "[email protected]",
| "redirect_url": "http://localhost:8082"
| }
| Please note that JWT must be signed with the counterpart private kew of the public key used to establish mTLS
|
|""",
ConsumerJwtPostJsonV510("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkZXNjcmlwdGlvbiI6IkRlc2NyaXB0aW9uIn0.qDnzk1dGK8akdLFRl8fmJV_SeoDjRTDG_eMogCIzZ7M"),
consumerJsonV510,
List(
InvalidJsonFormat,
UnknownError
),
List(apiTagDirectory, apiTagConsumer),
Some(Nil))


lazy val createConsumer: OBPEndpoint = {
case "dynamic-registration" :: "consumers" :: Nil JsonPost json -> _ => {
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
postedJwt <- NewStyle.function.tryons(InvalidJsonFormat, 400, cc.callContext) {
json.extract[ConsumerJwtPostJsonV510]
}
pem = APIUtil.`getPSD2-CERT`(cc.requestHeaders)
_ <- Helper.booleanToFuture(PostJsonIsNotSigned, 400, cc.callContext) {
verifyJwt(postedJwt.jwt, pem.getOrElse(""))
}
postedJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, cc.callContext) {
parse(getSignedPayloadAsJson(postedJwt.jwt).getOrElse("{}")).extract[ConsumerPostJsonV510]
}
certificateInfo: CertificateInfoJsonV510 <- Future(X509.getCertificateInfo(pem)) map {
unboxFullOrFail(_, cc.callContext, X509GeneralError)
}
_ <- Helper.booleanToFuture(RegulatedEntityNotFoundByCertificate, 400, cc.callContext) {
MappedRegulatedEntityProvider.getRegulatedEntities()
.exists(_.entityCertificatePublicKey.replace("""\n""", "") == pem.getOrElse("").replace("""\n""", ""))
}
(consumer, callContext) <- createConsumerNewStyle(
key = Some(Helpers.randomString(40).toLowerCase),
secret = Some(Helpers.randomString(40).toLowerCase),
isActive = Some(true),
name = getCommonName(pem).or(postedJson.app_name) ,
appType = postedJson.app_type.map(AppType.valueOf).orElse(Some(AppType.valueOf("Confidential"))),
description = Some(postedJson.description),
developerEmail = getEmailAddress(pem).or(postedJson.developer_email),
company = getOrganization(pem),
redirectURL = postedJson.redirect_url,
createdByUserId = None,
clientCertificate = pem,
cc.callContext
)
} yield {
// Format the data as json
val json = JSONFactory510.createConsumerJSON(consumer, Some(certificateInfo))
// Return
(json, HttpCode.`201`(callContext))
}
}
}


private def consumerDisabledText() = {
if(APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false) == false) {
"Please note: Your consumer may be disabled as a result of this action."
} else {
""
}
}

staticResourceDocs += ResourceDoc(
updateConsumerRedirectUrl,
implementedInApiVersion,
Expand All @@ -1780,6 +1872,8 @@ trait APIMethods510 {
"/management/consumers/CONSUMER_ID/consumer/redirect_url",
"Update Consumer RedirectUrl",
s"""Update an existing redirectUrl for a Consumer specified by CONSUMER_ID.
|
| ${consumerDisabledText()}
|
| CONSUMER_ID can be obtained after you register the application.
|
Expand Down
25 changes: 21 additions & 4 deletions obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,25 @@ case class MetricJsonV510(
)
case class MetricsJsonV510(metrics: List[MetricJsonV510])


case class ConsumerJwtPostJsonV510(jwt: String)
case class ConsumerPostJsonV510(app_name: Option[String],
app_type: Option[String],
description: String,
developer_email: Option[String],
redirect_url: Option[String],
)
case class ConsumerJsonV510(consumer_id: String,
consumer_key: String,
consumer_secret: String,
app_name: String,
app_type: String,
description: String,
developer_email: String,
company: String,
redirect_url: String,
created_by_user_id: String,
certificate_pem: String,
certificate_info: Option[CertificateInfoJsonV510],
created_by_user: ResourceUserJSON,
enabled: Boolean,
created: Date
Expand Down Expand Up @@ -620,7 +632,7 @@ object JSONFactory510 extends CustomJsonFormats {
MetricsJsonV510(metrics.map(createMetricJson))
}

def createConsumerJSON(c: Consumer): ConsumerJsonV510 = {
def createConsumerJSON(c: Consumer, certificateInfo: Option[CertificateInfoJsonV510] = None): ConsumerJsonV510 = {

val resourceUserJSON = Users.users.vend.getUserByUserId(c.createdByUserId.toString()) match {
case Full(resourceUser) => ResourceUserJSON(
Expand All @@ -633,13 +645,18 @@ object JSONFactory510 extends CustomJsonFormats {
case _ => null
}

ConsumerJsonV510(consumer_id = c.consumerId.get,
ConsumerJsonV510(
consumer_id = c.consumerId.get,
consumer_key = c.key.get,
consumer_secret = c.secret.get,
app_name = c.name.get,
app_type = c.appType.toString(),
description = c.description.get,
developer_email = c.developerEmail.get,
company = c.company.get,
redirect_url = c.redirectURL.get,
created_by_user_id = c.createdByUserId.get,
certificate_pem = c.clientCertificate.get,
certificate_info = certificateInfo,
created_by_user = resourceUserJSON,
enabled = c.isActive.get,
created = c.createdAt.get
Expand Down

0 comments on commit b6de507

Please sign in to comment.