Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

VRP Consent Acceptance #2484

Merged
merged 13 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion obp-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,12 @@
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.9.1</version>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>logging-interceptor</artifactId>
<version>4.12.0</version>
</dependency>

<!-- https://mvnrepository.com/artifact/com.rabbitmq/amqp-client -->
Expand Down
10 changes: 9 additions & 1 deletion obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -764,14 +764,22 @@ display_internal_errors=false
# URL of Public server JWK set used for validating bearer JWT access tokens
# It can contain more than one URL i.e. list of uris. Values are comma separated.
# oauth2.jwk_set.url=http://localhost:8080/jwk.json,https://www.googleapis.com/oauth2/v3/certs
# ------------------------------------------------------------------------------ OAuth 2 ------

# -- Keycloak OAuth 2 ---------------------------------------------------------------------------
# integrate_with_keycloak = false
# Keycloak Identity Provider Host
# oauth2.keycloak.host=http://localhost:7070
# Keycloak access token to make a call to Admin APIs (This props is likely to change)
# keycloak.admin.access_token =
# Keycloak Identity Provider Realm (Multi-Tenancy Support)
# oauth2.keycloak.realm = master
# oauth2.keycloak.well_known=http://localhost:7070/realms/master/.well-known/openid-configuration
# Used to sync IAM of OBP-API and IAM of Keycloak
# oauth2.keycloak.source_of_truth = false
# Resource access object allowed to sync IAM of OBP-API and IAM of Keycloak
# oauth2.keycloak.resource_access_key_name_to_trust = open-bank-project
# ------------------------------------------------------------------------------ OAuth 2 ------
# ------------------------------------------------------------------------ Keycloak OAuth 2 ------

# -- PSU Authentication methods --------------------------------------------------------------
# The EBA notes that there would appear to currently be three main ways or methods
Expand Down
2 changes: 2 additions & 0 deletions obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,8 @@ class Boot extends MdcLoggable {
Menu.i("Introduction") / "introduction",
Menu.i("add-user-auth-context-update-request") / "add-user-auth-context-update-request",
Menu.i("confirm-user-auth-context-update-request") / "confirm-user-auth-context-update-request",
Menu.i("confirm-bg-consent-request") / "confirm-bg-consent-request" >> AuthUser.loginFirst,//OAuth consent page,
Menu.i("confirm-bg-consent-request-sca") / "confirm-bg-consent-request-sca" >> AuthUser.loginFirst,//OAuth consent page,
Menu.i("confirm-vrp-consent-request") / "confirm-vrp-consent-request" >> AuthUser.loginFirst,//OAuth consent page,
Menu.i("confirm-vrp-consent") / "confirm-vrp-consent" >> AuthUser.loginFirst //OAuth consent page
) ++ accountCreation ++ Admin.menus
Expand Down
36 changes: 36 additions & 0 deletions obp-api/src/main/scala/code/api/util/DateTimeUtil.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package code.api.util

import java.time.Duration

object DateTimeUtil {

/*
Examples:
println(formatDuration(90000)) // "1 day, 1 hour"
println(formatDuration(86400)) // "1 day"
println(formatDuration(172800)) // "2 days"
println(formatDuration(7200)) // "2 hours"
println(formatDuration(3661)) // "1 hour, 1 minute, 1 second"
println(formatDuration(120)) // "2 minutes"
println(formatDuration(30)) // "30 seconds"
println(formatDuration(0)) // "less than a second"
*/
def formatDuration(seconds: Long): String = {
val days = seconds / 86400
val hours = (seconds % 86400) / 3600
val minutes = (seconds % 3600) / 60
val secs = seconds % 60

def plural(value: Long, unit: String): Option[String] =
if (value > 0) Some(s"$value ${unit}${if (value > 1) "s" else ""}") else None

val parts = List(
plural(days, "day"),
plural(hours, "hour"),
plural(minutes, "minute"),
plural(secs, "second")
).flatten // Remove None values

if (parts.isEmpty) "less than a second" else parts.mkString(", ")
}
}
107 changes: 107 additions & 0 deletions obp-api/src/main/scala/code/api/util/KeycloakAdmin.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package code.api.util


import code.api.OAuth2Login.Keycloak
import code.model.{AppType, Consumer}
import net.liftweb.common.{Box, Failure, Full}
import okhttp3._
import okhttp3.logging.HttpLoggingInterceptor
import org.slf4j.LoggerFactory


object KeycloakAdmin {

// Initialize Logback logger
private val logger = LoggerFactory.getLogger("okhttp3")

val integrateWithKeycloak = APIUtil.getPropsAsBoolValue("integrate_with_keycloak", defaultValue = false)
// Define variables (replace with actual values)
private val keycloakHost = Keycloak.keycloakHost
private val realm = APIUtil.getPropsValue(nameOfProperty = "oauth2.keycloak.realm", "master")
private val accessToken = APIUtil.getPropsValue(nameOfProperty = "keycloak.admin.access_token", "")

def createHttpClientWithLogback(): OkHttpClient = {
val builder = new OkHttpClient.Builder()
val logging = new HttpLoggingInterceptor(new HttpLoggingInterceptor.Logger {
override def log(message: String): Unit = logger.debug(message)
})
logging.setLevel(HttpLoggingInterceptor.Level.BODY) // Log full request/response details
builder.addInterceptor(logging)
builder.build()
}
// Create OkHttp client with logging
val client = createHttpClientWithLogback()

def createKeycloakConsumer(consumer: Consumer): Box[Boolean] = {
val isPublic =
AppType.valueOf(consumer.appType.get) match {
case AppType.Confidential => false
case _ => true
}
createClient(
clientId = consumer.key.get,
secret = consumer.secret.get,
name = consumer.name.get,
description = consumer.description.get,
redirectUri = consumer.redirectURL.get,
isPublic = isPublic,
)
}
def createClient(clientId: String,
secret: String,
name: String,
description: String,
redirectUri: String,
isPublic: Boolean,
realm: String = realm
) = {
val url = s"$keycloakHost/admin/realms/$realm/clients"
// JSON request body
val jsonBody =
s"""{
| "clientId": "$clientId",
| "name": "$name",
| "description": "$description",
| "redirectUris": ["$redirectUri"],
| "enabled": true,
| "clientAuthenticatorType": "client-secret",
| "directAccessGrantsEnabled": true,
| "standardFlowEnabled": true,
| "implicitFlowEnabled": false,
| "serviceAccountsEnabled": true,
| "publicClient": $isPublic,
| "secret": "$secret"
|}""".stripMargin

// Define the request with headers and JSON body
val requestBody = RequestBody.create(MediaType.get("application/json; charset=utf-8"), jsonBody)

val request = new Request.Builder()
.url(url)
.post(requestBody)
.addHeader("Authorization", s"Bearer $accessToken")
.addHeader("Content-Type", "application/json")
.build()

makeAndHandleHttpCall(request)
}

private def makeAndHandleHttpCall(request: Request): Box[Boolean] = {
// Execute the request
try {
val response = client.newCall(request).execute()
if (response.isSuccessful) {
logger.debug(s"Response: ${response.body.string}")
Full(response.isSuccessful)
} else {
logger.error(s"Request failed with status code: ${response.code}")
logger.debug(s"Response: ${response}")
Failure(s"code: ${response.code} message: ${response.message}")
}
} catch {
case e: Exception =>
logger.error(s"Error occurred: ${e.getMessage}")
Failure(e.getMessage)
}
}
}
132 changes: 132 additions & 0 deletions obp-api/src/main/scala/code/snippet/BerlinGroupConsent.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
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: [email protected]
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.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.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.util.Helper.{MdcLoggable, ObpS}
import net.liftweb.common.{Box, Failure, Full}
import net.liftweb.http.rest.RestHelper
import net.liftweb.http.{RequestVar, S, SHtml, SessionVar}
import net.liftweb.json.{Formats, parse}
import net.liftweb.util.CssSel
import net.liftweb.util.Helpers._

import scala.collection.immutable

class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510 with APIMethods500 with APIMethods310 {
protected implicit override def formats: Formats = CustomJsonFormats.formats

private object otpValue extends RequestVar("123456")
private object redirectUriValue extends SessionVar("")

def confirmBerlinGroupConsentRequest: CssSel = {
callGetConsentByConsentId() match {
case Full(consent) =>
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 =>
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)
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


"#confirm-bg-consent-request-form-title *" #> s"Please confirm or deny the following consent request:" &
"#confirm-bg-consent-request-form-text *" #> s"""$formText""" &
"#confirm-bg-consent-request-confirm-submit-button" #> SHtml.onSubmitUnit(confirmConsentRequestProcess)
"#confirm-bg-consent-request-deny-submit-button" #> SHtml.onSubmitUnit(denyConsentRequestProcess)
case everythingElse =>
S.error(everythingElse.toString)
"#confirm-bg-consent-request-form-title *" #> s"Please confirm or deny the following consent request:" &
"type=submit" #> ""
}
}

private def callGetConsentByConsentId(): Box[MappedConsent] = {
val requestParam = List(
ObpS.param("CONSENT_ID"),
)
if (requestParam.count(_.isDefined) < requestParam.size) {
Failure("Parameter CONSENT_ID is missing, please set it in the URL")
} else {
val consentId = ObpS.param("CONSENT_ID") openOr ("")
Consents.consentProvider.vend.getConsentByConsentId(consentId)
}
}

private def confirmConsentRequestProcess() = {
val consentId = ObpS.param("CONSENT_ID") openOr ("")
S.redirectTo(
s"/confirm-bg-consent-request-sca?CONSENT_ID=${consentId}"
)
}
private def denyConsentRequestProcess() = {
val consentId = ObpS.param("CONSENT_ID") openOr ("")
Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.rejected)
S.redirectTo(
s"$redirectUriValue?CONSENT_ID=${consentId}"
)
}
private def confirmConsentRequestProcessSca() = {
val consentId = ObpS.param("CONSENT_ID") openOr ("")
Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.valid)
S.redirectTo(
s"$redirectUriValue?CONSENT_ID=${consentId}"
)
}


def confirmBgConsentRequest: CssSel = {
"#otp-value" #> SHtml.textElem(otpValue) &
"type=submit" #> SHtml.onSubmitUnit(confirmConsentRequestProcessSca)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ TESOBE (http://www.tesobe.com/)
package code.snippet

import java.util

import code.api.{Constant, DirectLogin}
import code.api.util.{APIUtil, ErrorMessages, X509}
import code.api.util.{APIUtil, ErrorMessages, KeycloakAdmin, X509}
import code.consumer.Consumers
import code.model.dataAccess.AuthUser
import code.model.{Consumer, _}
Expand Down Expand Up @@ -176,6 +175,10 @@ class ConsumerRegistration extends MdcLoggable {
oAuth2Client
})
}

// In case we use Keycloak as Identity Provider we create corresponding client at Keycloak side a well
if(KeycloakAdmin.integrateWithKeycloak) KeycloakAdmin.createKeycloakConsumer(consumer)

val registerConsumerSuccessMessageWebpage = getWebUiPropsValue(
"webui_register_consumer_success_message_webpage",
"Thanks for registering your consumer with the Open Bank Project API! Here is your developer information. Please save it in a secure location.")
Expand Down
Loading