Skip to content
Open
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
5 changes: 5 additions & 0 deletions obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -1682,6 +1682,11 @@ default_auth_context_update_request_key=CUSTOMER_NUMBER
## Berlin Group Create Consent ASPSP-SCA-Approach response header value
#berlin_group_aspsp_sca_approach = redirect

# Whether a Berlin Group signing basket may be authorised (PUT /signing-baskets/{basketId}/authorisations/{authorisationId}).
# Default false: the call answers 403 SERVICE_BLOCKED. Answering the authorisation books the basket's payments one
# after another. Creating, reading, starting an authorisation on and deleting baskets are not affected.
#signing_basket_authorisation_enabled = false

# Support multiple brands on one instance. Note this needs checking on a clustered environment
#brands_enabled=false

Expand Down
10 changes: 10 additions & 0 deletions obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,15 @@ object ConstantsBG {
// 4) CANC (Cancelled) and
// 5) RJCT (Rejected) are supported for signing baskets.
val RCVD, PATC, ACTC, CANC, RJCT = Value

/**
* Stored from the moment a correct answer claims the basket until its payments have been booked. It is
* never reported: to a TPP a basket in this state is still RCVD, since the authorisation has not
* completed from its point of view.
*/
val AUTHORISING_INTERNAL = "AUTHORISING"

def external(storedStatus: String): String =
if (storedStatus == AUTHORISING_INTERNAL) RCVD.toString else storedStatus
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ object Http4sBGv13PIS extends MdcLoggable {
* a payment lodged on a client-credentials token can still be authorised under the PSU's token
* and the other way round. A payment carrying neither identity belongs to nobody.
*/
private def getOwnPaymentImpl(paymentId: String, callContext: Option[CallContext]): OBPReturnType[TransactionRequest] =
def getOwnPaymentImpl(paymentId: String, callContext: Option[CallContext]): OBPReturnType[TransactionRequest] =
for {
(transactionRequest, callContext) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext)
initiators = Set(transactionRequest.user_id, transactionRequest.on_behalf_of_user_id).flatten.filter(_.nonEmpty)
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{
transactionStatus: String,
basketId: String,
_links: SigningBasketLinksV13)
// The links of a signing basket authorisation: scaStatus is a hyperlink object (hrefType), not a bare string.
case class SigningBasketScaLinksV13(scaStatus: LinkHrefJson)
case class StartSigningBasketAuthorisationJson(
scaStatus: String,
authorisationId: String,
psuMessage: String,
_links: SigningBasketScaLinksV13)
case class SigningBasketGetResponseJson(
transactionStatus: String,
payments: Option[List[String]],
Expand Down Expand Up @@ -875,19 +882,35 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{
}


def createStartSigningBasketAuthorisationJson(basketId: String, challenge: ChallengeTrait): StartPaymentAuthorisationJson = {
StartPaymentAuthorisationJson(
def createStartSigningBasketAuthorisationJson(basketId: String, challenge: ChallengeTrait): StartSigningBasketAuthorisationJson = {
StartSigningBasketAuthorisationJson(
scaStatus = challenge.scaStatus.map(_.toString).getOrElse(""),
authorisationId = challenge.challengeId,
psuMessage = "Please check your SMS at a mobile device.",
_links = ScaStatusJsonV13(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basketId}/authorisations/${challenge.challengeId}")
_links = SigningBasketScaLinksV13(
scaStatus = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basketId}/authorisations/${challenge.challengeId}")
)
)
}

/** The 200 answer to a transaction authorisation on a signing basket: a scaStatusResponse whose link names the basket's authorisation. */
def createUpdateSigningBasketPsuDataJson(basketId: String, challenge: ChallengeTrait, executionIncomplete: Boolean = false) = {
ScaStatusResponse(
scaStatus = challenge.scaStatus.map(_.toString).getOrElse(""),
// The authorisation itself succeeded either way. When not every payment could be booked, the basket
// stays RCVD and each payment's own status says which was.
psuMessage = Some(
if (executionIncomplete) "The authorisation was accepted, but not every payment in the basket could be booked."
else "Please check your SMS at a mobile device."),
_links = Some(LinksAll(scaStatus = Some(HrefType(Some(
s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basketId}/authorisations/${challenge.challengeId}")))))
)
}

def createSigningBasketResponseJson(basket: SigningBasketTrait): SigningBasketResponseJson = {
SigningBasketResponseJson(
basketId = basket.basketId,
transactionStatus = basket.status.toLowerCase(),
transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.status),
_links = SigningBasketLinksV13(
self = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basket.basketId}"),
status = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basket.basketId}/status"),
Expand All @@ -898,15 +921,15 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{

def getSigningBasketResponseJson(basket: SigningBasketContent): SigningBasketGetResponseJson = {
SigningBasketGetResponseJson(
transactionStatus = basket.basket.status.toLowerCase(),
transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.basket.status),
payments = basket.payments,
consents = basket.consents,
)
}

def getSigningBasketStatusResponseJson(basket: SigningBasketContent): SigningBasketGetResponseJson = {
SigningBasketGetResponseJson(
transactionStatus = basket.basket.status.toLowerCase(),
transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.basket.status),
payments = None,
consents = None,
)
Expand Down
15 changes: 15 additions & 0 deletions obp-api/src/main/scala/code/api/util/BerlinGroupError.scala
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ object BerlinGroupError {
case "403" if message.contains("OBP-20060") => "ROLE_INVALID"

case "400" if message.contains("OBP-10034") => "PARAMETER_NOT_CONSISTENT"
case "400" if message.contains("OBP-35050") => "SERVICE_INVALID"
// One answer for a signing basket that does not exist and one the caller may not address, so the
// endpoint is not a way to learn which basket ids exist.
case "403" if message.contains("OBP-35051") => "RESOURCE_UNKNOWN"
case "404" if message.contains("OBP-35052") => "RESOURCE_UNKNOWN"
case "409" if message.contains("OBP-35053") => "STATUS_INVALID"
case "403" if message.contains("OBP-35054") => "SERVICE_BLOCKED"
case "400" if message.contains("OBP-35056") => "RESOURCE_UNKNOWN"
case "409" if message.contains("OBP-35057") => "REFERENCE_STATUS_INVALID"
case "400" if message.contains("OBP-35058") => "SERVICE_INVALID"
// A wrong or expired one-time password on a signing basket. The standard's code for "the
// password/OTP is incorrect" is a 401 one; the basket answers these at 401 so it can use it.
case "401" if message.contains("OBP-40016") => "PSU_CREDENTIALS_INVALID"
case "401" if message.contains("OBP-20211") => "PSU_CREDENTIALS_INVALID"
case "401" if message.contains("OBP-40014") => "PSU_CREDENTIALS_INVALID"

case "400" if message.contains("OBP-35018") => "CONSENT_UNKNOWN"
case "400" if message.contains("OBP-35001") => "CONSENT_UNKNOWN"
Expand Down
10 changes: 10 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 @@ -881,6 +881,16 @@ object ErrorMessages {
val InvalidUKConsentPermissions = "OBP-35038: The Permissions array is not a valid combination for UK Open Banking. "
val BerlinGroupPsuNotIdentified = "OBP-35039: The PSU this authorisation is for cannot be identified. Send the PSU-ID header, or authenticate as the PSU. "
val ConsentNamesNoAccount = "OBP-35040: The Consent names no account, so it grants no access. It was authorised before consents were bound to accounts; re-authorise it to select which accounts it applies to. "
val SigningBasketAuthorisationVariantNotSupported = "OBP-35050: This request body is not supported on a signing basket authorisation. " +
"Send an empty body to start the authorisation, or {\"scaAuthenticationData\": ...} to answer it. " +
"PSU authentication, authentication method selection and confirmation code requests are not available for signing baskets. "
val SigningBasketNotFound = "OBP-35051: Signing basket not found by BASKET_ID. "
val SigningBasketAuthorisationNotFound = "OBP-35052: Signing basket authorisation not found by AUTHORISATION_ID. "
val SigningBasketStatusInvalid = "OBP-35053: The signing basket's status does not allow this operation. "
val SigningBasketAuthorisationDisabled = "OBP-35054: Authorising signing baskets is not enabled at this instance. "
val SigningBasketMemberNotFound = "OBP-35056: A payment named for the signing basket was not found. "
val SigningBasketMemberStatusInvalid = "OBP-35057: A payment named for the signing basket is not waiting for SCA. "
val SigningBasketConsentsNotSupported = "OBP-35058: Consents in a signing basket are not supported yet. Name payments only. "
val ConsentMyResourcesInvalid = "OBP-35042: The Consent's my_resources block is invalid. "
val ConsentMyResourcesMissing = "OBP-35043: The Consent does not cover this personal resource. A consent user may use a personal (my) endpoint only if the Consent lists the resource in my_resources with the needed action. "
val ConsentAccountAccessCannotBeGranted = "OBP-35041: The Consent's account access cannot be granted. The Consent has not been authorised; please retry the authorisation. "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,39 +27,56 @@ TESOBE (http://www.tesobe.com/)

package code.api.util.newstyle

import code.api.util.APIUtil.{OBPReturnType, unboxFullOrFail}
import code.api.berlin.group.v1_3.Http4sBGv13PIS
import code.api.util.APIUtil.unboxFullOrFail
import code.api.util.CallContext
import code.api.util.ErrorMessages.{InvalidConnectorResponse, RegulatedEntityNotDeleted}
import code.bankconnectors.Connector
import code.api.util.ErrorMessages.{SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, SigningBasketNotFound}
import code.signingbaskets.SigningBasketX
import com.openbankproject.commons.model.TransactionRequestId
import code.util.Helper.booleanToFuture
import com.openbankproject.commons.model.SigningBasketContent
import net.liftweb.common.{Box, Empty}

import scala.concurrent.Future
import scala.util.{Failure, Success}

object SigningBasketNewStyle {

import com.openbankproject.commons.ExecutionContext.Implicits.global

def checkSigningBasketPayments(basketId: String,
callContext: Option[CallContext]
): OBPReturnType[Boolean] = {
Future {
val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId)
val existAll: Box[Boolean] =
basket.flatMap(_.payments.map(_.forall(i => Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), callContext).isDefined)))
if (existAll.getOrElse(false)) {
Some(true)
} else { // Fail due to nonexistent payment
val paymentIds = basket.flatMap(_.payments).getOrElse(Nil).mkString(",")
unboxFullOrFail(Empty, callContext, s"$InvalidConnectorResponse Some of paymentIds [${paymentIds}] are invalid")
}
} map {
(_, callContext)
} map {
x => (unboxFullOrFail(x._1, callContext, RegulatedEntityNotDeleted, 400), x._2)
/**
* The basket, if the caller may address it: only the consumer (TPP) that created it. A basket that does
* not exist, one another TPP created, and one created before ownership was recorded all answer the same
* way, so the endpoint is not a way to learn which basket ids exist.
*/
def getOwnBasket(basketId: String, callContext: Option[CallContext]): Future[SigningBasketContent] = {
val callingConsumer = callContext.flatMap(_.consumer.toOption).map(_.consumerId.get)
Future(SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId).toOption).flatMap { found =>
booleanToFuture(SigningBasketNotFound, failCode = 403, cc = callContext) {
found.exists(content => content.basket.consumerId.isDefined && content.basket.consumerId == callingConsumer)
}.map(_ => found.get)
}
}

// A payment lodged for SCA is stored RCVD (BG initiation) or INITIATED; anything else has been booked,
// rejected or cancelled, or is being authorised some other way.
val awaitingScaPaymentStatuses = Set("RCVD", "INITIATED")

/**
* A payment may join a basket if the caller lodged it (the rule the payment routes use) and it is still
* waiting for SCA. One that does not exist and one that is somebody else's answer alike.
*/
def admitPayments(paymentIds: List[String], callContext: Option[CallContext]): Future[Unit] =
paymentIds.foldLeft(Future.successful(())) { (previous, paymentId) =>
previous.flatMap(_ => admitPayment(paymentId, callContext))
}

private def admitPayment(paymentId: String, callContext: Option[CallContext]): Future[Unit] =
Http4sBGv13PIS.getOwnPaymentImpl(paymentId, callContext).transformWith {
case Success((payment, _)) =>
booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) {
awaitingScaPaymentStatuses.contains(payment.status)
}.map(_ => ())
case Failure(_) =>
Future(unboxFullOrFail(Empty: Box[Unit], callContext, SigningBasketMemberNotFound, 400))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import code.util.MappedUUID
import com.openbankproject.commons.model.{SigningBasketConsentTrait, SigningBasketContent, SigningBasketPaymentTrait, SigningBasketTrait}
import net.liftweb.common.Box
import net.liftweb.common.Box.tryo
import net.liftweb.db.DB
import net.liftweb.mapper._

object MappedSigningBasketProvider extends SigningBasketProvider {
Expand All @@ -41,7 +42,7 @@ object MappedSigningBasketProvider extends SigningBasketProvider {

override def getSigningBasketByBasketId(entityId: String): Box[SigningBasketContent] = {
val basket: Box[MappedSigningBasket] = MappedSigningBasket.find(By(MappedSigningBasket.BasketId, entityId))
val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, entityId)).map(_.paymentId) match {
val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, entityId), OrderBy(MappedSigningBasketPayment.id, Ascending)).map(_.paymentId) match {
case Nil => None
case head :: tail => Some(head :: tail)
}
Expand All @@ -53,7 +54,7 @@ object MappedSigningBasketProvider extends SigningBasketProvider {
}
override def saveSigningBasketStatus(entityId: String, status: String): Box[SigningBasketContent] = {
val basket: Box[MappedSigningBasket] = MappedSigningBasket.find(By(MappedSigningBasket.BasketId, entityId)).map(_.Status(status).saveMe)
val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, entityId)).map(_.paymentId) match {
val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, entityId), OrderBy(MappedSigningBasketPayment.id, Ascending)).map(_.paymentId) match {
case Nil => None
case head :: tail => Some(head :: tail)
}
Expand All @@ -65,11 +66,13 @@ object MappedSigningBasketProvider extends SigningBasketProvider {
}

override def createSigningBasket(paymentIds: Option[List[String]],
consentIds: Option[List[String]]
consentIds: Option[List[String]],
consumerId: String
): Box[SigningBasketTrait] = {
tryo {
val entity = MappedSigningBasket.create
entity.Status(ConstantsBG.SigningBasketsStatus.RCVD.toString)
entity.ConsumerId(consumerId)

if (entity.validate.isEmpty) {
entity.saveMe()
Expand All @@ -86,6 +89,14 @@ object MappedSigningBasketProvider extends SigningBasketProvider {
}
}

override def transitionSigningBasketStatus(basketId: String, from: String, to: String): Box[Boolean] =
tryo {
DB.runUpdate(
s"UPDATE ${MappedSigningBasket.dbTableName} SET ${MappedSigningBasket.Status._dbColumnNameLC} = ? " +
s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? AND ${MappedSigningBasket.Status._dbColumnNameLC} = ?",
List(to, basketId, from)) == 1
}

override def deleteSigningBasket(id: String): Box[Boolean] = {
MappedSigningBasket.find(By(MappedSigningBasket.BasketId, id)) map {
_.Status(ConstantsBG.SigningBasketsStatus.CANC.toString).save
Expand All @@ -98,11 +109,12 @@ class MappedSigningBasket extends SigningBasketTrait with LongKeyedMapper[Mapped
override def getSingleton = MappedSigningBasket
object BasketId extends MappedUUID(this)
object Status extends MappedString(this, 50)


// The consumer (TPP) that created the basket. Empty, or null, on a basket created before this was recorded.
object ConsumerId extends MappedString(this, 255)

override def basketId: String = BasketId.get
override def status: String = Status.get
override def consumerId: Option[String] = Option(ConsumerId.get).map(_.trim).filter(_.nonEmpty)

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,19 @@ trait SigningBasketProvider extends MdcLoggable {
def getSigningBasketByBasketId(entityId: String): Box[SigningBasketContent]
def saveSigningBasketStatus(entityId: String, status: String): Box[SigningBasketContent]

/** Creates the basket, owned by the consumer (TPP) that creates it. */
def createSigningBasket(paymentIds: Option[List[String]],
consentIds: Option[List[String]],
consumerId: String
): Box[SigningBasketTrait]

/**
* Moves a basket from one status to another only if it still has the status the caller read.
* One conditional update, so two callers racing for the same transition have exactly one winner.
* Returns whether this call made the move.
*/
def transitionSigningBasketStatus(basketId: String, from: String, to: String): Box[Boolean]

def deleteSigningBasket(id: String): Box[Boolean]

}
Loading
Loading