diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala index 9ba607c7bc..6db773c921 100644 --- a/obp-api/src/main/scala/code/api/OAuth2.scala +++ b/obp-api/src/main/scala/code/api/OAuth2.scala @@ -358,8 +358,8 @@ object OAuth2Login extends RestHelper with MdcLoggable { } } - def getClaim(name: String, idToken: String): Option[String] = { - val claim = JwtUtil.getClaim(name = name, jwtToken = idToken) + def getClaim(name: String, jwtToken: String): Option[String] = { + val claim = JwtUtil.getClaim(name = name, jwtToken = jwtToken) claim match { case null => None case string => Some(string) @@ -385,7 +385,7 @@ object OAuth2Login extends RestHelper with MdcLoggable { * It is mapped in next way: * iss => ResourceUser.provider_ * sub => ResourceUser.providerId - * @param idToken Google's response example: + * @param jwtToken Google's response example: * { * "access_token": "ya29.GluUBg5DflrJciFikW5hqeKEp9r1whWnU5x2JXCm9rKkRMs2WseXX8O5UugFMDsIKuKCZlE7tTm1fMII_YYpvcMX6quyR5DXNHH8Lbx5TrZN__fA92kszHJEVqPc", * "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6Im5HS1JUb0tOblZBMjhINk1od1hCeHciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzA1NjkxLCJleHAiOjE1NDc3MDkyOTF9.iUxhF_SU2vi76zPuRqAKJvFOzpb_EeP3lc5u9FO9o5xoXzVq3QooXexTfK2f1YAcWEy9LSftA34PB0QTuCZpkQChZVM359n3a3hplf6oWWkBXZN2_IG10NwEH4g0VVBCsjWBDMp6lvepN_Zn15x8opUB7272m4-smAou_WmUPTeivXRF8yPcp4J55DigcY31YP59dMQr2X-6Rr1vCRnJ6niqqJ1UDldfsgt4L7dXmUCnkDdXHwEQAZwbKbR4dUoEha3QeylCiBErmLdpIyqfKECphC6piGXZB-rRRqLz41WNfuF-3fswQvGmIkzTJDR7lQaletMp7ivsfVw8N5jFxg", @@ -396,15 +396,15 @@ object OAuth2Login extends RestHelper with MdcLoggable { * } * @return an existing or a new user */ - def getOrCreateResourceUserFuture(idToken: String): Future[Box[User]] = { - val uniqueIdGivenByProvider = JwtUtil.getSubject(idToken).getOrElse("") - val provider = resolveProvider(idToken) + def getOrCreateResourceUserFuture(jwtToken: String): Future[Box[User]] = { + val uniqueIdGivenByProvider = JwtUtil.getSubject(jwtToken).getOrElse("") + val provider = resolveProvider(jwtToken) Users.users.vend.getOrCreateUserByProviderIdFuture( provider = provider, idGivenByProvider = uniqueIdGivenByProvider, consentId = None, - name = getClaim(name = "given_name", idToken = idToken).orElse(Some(uniqueIdGivenByProvider)), - email = getClaim(name = "email", idToken = idToken) + name = getClaim(name = "given_name", jwtToken = jwtToken).orElse(Some(uniqueIdGivenByProvider)), + email = getClaim(name = "email", jwtToken = jwtToken) ).map(_._1) } /** Old Style Endpoints @@ -412,7 +412,7 @@ object OAuth2Login extends RestHelper with MdcLoggable { * It is mapped in next way: * iss => ResourceUser.provider_ * sub => ResourceUser.providerId - * @param idToken Google's response example: + * @param jwtToken Google's response example: * { * "access_token": "ya29.GluUBg5DflrJciFikW5hqeKEp9r1whWnU5x2JXCm9rKkRMs2WseXX8O5UugFMDsIKuKCZlE7tTm1fMII_YYpvcMX6quyR5DXNHH8Lbx5TrZN__fA92kszHJEVqPc", * "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6Im5HS1JUb0tOblZBMjhINk1od1hCeHciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzA1NjkxLCJleHAiOjE1NDc3MDkyOTF9.iUxhF_SU2vi76zPuRqAKJvFOzpb_EeP3lc5u9FO9o5xoXzVq3QooXexTfK2f1YAcWEy9LSftA34PB0QTuCZpkQChZVM359n3a3hplf6oWWkBXZN2_IG10NwEH4g0VVBCsjWBDMp6lvepN_Zn15x8opUB7272m4-smAou_WmUPTeivXRF8yPcp4J55DigcY31YP59dMQr2X-6Rr1vCRnJ6niqqJ1UDldfsgt4L7dXmUCnkDdXHwEQAZwbKbR4dUoEha3QeylCiBErmLdpIyqfKECphC6piGXZB-rRRqLz41WNfuF-3fswQvGmIkzTJDR7lQaletMp7ivsfVw8N5jFxg", @@ -423,9 +423,9 @@ object OAuth2Login extends RestHelper with MdcLoggable { * } * @return an existing or a new user */ - def getOrCreateResourceUser(idToken: String): Box[User] = { - val uniqueIdGivenByProvider = JwtUtil.getSubject(idToken).getOrElse("") - val provider = resolveProvider(idToken) + def getOrCreateResourceUser(jwtToken: String): Box[User] = { + val uniqueIdGivenByProvider = JwtUtil.getSubject(jwtToken).getOrElse("") + val provider = resolveProvider(jwtToken) KeycloakFederatedUserReference.parse(uniqueIdGivenByProvider) match { case Right(fedRef) => // Users log on via Keycloak, which uses User Federation to access the external OBP database. logger.debug(s"External ID = ${fedRef.externalId}") @@ -438,8 +438,8 @@ object OAuth2Login extends RestHelper with MdcLoggable { provider = provider, providerId = Some(uniqueIdGivenByProvider), None, - name = getClaim(name = "given_name", idToken = idToken).orElse(Some(uniqueIdGivenByProvider)), - email = getClaim(name = "email", idToken = idToken), + name = getClaim(name = "given_name", jwtToken = jwtToken).orElse(Some(uniqueIdGivenByProvider)), + email = getClaim(name = "email", jwtToken = jwtToken), userId = None, createdByUserInvitationId = None, company = None, @@ -449,21 +449,31 @@ object OAuth2Login extends RestHelper with MdcLoggable { } } - def resolveProvider(idToken: String) = { - HydraUtil.integrateWithHydra && isIssuer(jwtToken = idToken, identityProvider = hydraPublicUrl) match { - case true if HydraUtil.hydraUsesObpUserCredentials => // Case that source of the truth of Hydra user management is the OBP-API mapper DB - logger.debug("resolveProvider says: we are in Hydra ") - // In case that ORY Hydra login url is "hostname/user_mgt/login" we MUST override hydraPublicUrl as provider - // in order to avoid creation of a new user - Constant.localIdentityProvider - // if its OBPOIDC issuer - case false if OBPOIDC.isIssuer(idToken) => - logger.debug("resolveProvider says: we are in OBPOIDC ") - Constant.localIdentityProvider - case _ => // All other cases implies a new user creation - logger.debug("resolveProvider says: Other cases ") - // TODO raise exception in case of else case - JwtUtil.getIssuer(idToken).getOrElse("") + def resolveProvider(jwtToken: String) = { + // First try to get provider from token's provider claim + val providerFromToken = JwtUtil.getProvider(jwtToken) + + providerFromToken match { + case Some(provider) => + logger.debug(s"resolveProvider says: using provider from token claim: $provider") + provider + case None => + // Fallback to existing logic if provider claim is not present + HydraUtil.integrateWithHydra && isIssuer(jwtToken = jwtToken, identityProvider = hydraPublicUrl) match { + case true if HydraUtil.hydraUsesObpUserCredentials => // Case that source of the truth of Hydra user management is the OBP-API mapper DB + logger.debug(s"resolveProvider says: we are in Hydra, use Constant.localIdentityProvider ${Constant.localIdentityProvider}") + // In case that ORY Hydra login url is "hostname/user_mgt/login" we MUST override hydraPublicUrl as provider + // in order to avoid creation of a new user + Constant.localIdentityProvider + // if its OBPOIDC issuer + case false if OBPOIDC.isIssuer(jwtToken) => + logger.debug(s"resolveProvider says: we are in OBP-OIDC, use Constant.localIdentityProvider ${Constant.localIdentityProvider}") + Constant.localIdentityProvider + case _ => // All other cases implies a new user creation + logger.debug("resolveProvider says: Other cases ") + // TODO raise exception in case of else case + JwtUtil.getIssuer(jwtToken).getOrElse("") + } } } @@ -473,7 +483,7 @@ object OAuth2Login extends RestHelper with MdcLoggable { * Unique criteria to decide do we create or get a consumer is pair o values: < sub : azp > i.e. * We cannot find consumer by sub and azp => Create * We can find consumer by sub and azp => Get - * @param idToken Google's response example: + * @param jwtToken Google's response example: * { * "access_token": "ya29.GluUBg5DflrJciFikW5hqeKEp9r1whWnU5x2JXCm9rKkRMs2WseXX8O5UugFMDsIKuKCZlE7tTm1fMII_YYpvcMX6quyR5DXNHH8Lbx5TrZN__fA92kszHJEVqPc", * "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6Im5HS1JUb0tOblZBMjhINk1od1hCeHciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzA1NjkxLCJleHAiOjE1NDc3MDkyOTF9.iUxhF_SU2vi76zPuRqAKJvFOzpb_EeP3lc5u9FO9o5xoXzVq3QooXexTfK2f1YAcWEy9LSftA34PB0QTuCZpkQChZVM359n3a3hplf6oWWkBXZN2_IG10NwEH4g0VVBCsjWBDMp6lvepN_Zn15x8opUB7272m4-smAou_WmUPTeivXRF8yPcp4J55DigcY31YP59dMQr2X-6Rr1vCRnJ6niqqJ1UDldfsgt4L7dXmUCnkDdXHwEQAZwbKbR4dUoEha3QeylCiBErmLdpIyqfKECphC6piGXZB-rRRqLz41WNfuF-3fswQvGmIkzTJDR7lQaletMp7ivsfVw8N5jFxg", @@ -484,13 +494,13 @@ object OAuth2Login extends RestHelper with MdcLoggable { * } * @return an existing or a new consumer */ - def getOrCreateConsumer(idToken: String, userId: Box[String], description: Option[String]): Box[Consumer] = { - val aud = Some(JwtUtil.getAudience(idToken).mkString(",")) - val azp = getClaim(name = "azp", idToken = idToken) - val iss = getClaim(name = "iss", idToken = idToken) - val sub = getClaim(name = "sub", idToken = idToken) - val email = getClaim(name = "email", idToken = idToken) - val name = getClaim(name = "name", idToken = idToken).orElse(description) + def getOrCreateConsumer(jwtToken: String, userId: Box[String], description: Option[String]): Box[Consumer] = { + val aud = Some(JwtUtil.getAudience(jwtToken).mkString(",")) + val azp = getClaim(name = "azp", jwtToken = jwtToken) + val iss = getClaim(name = "iss", jwtToken = jwtToken) + val sub = getClaim(name = "sub", jwtToken = jwtToken) + val email = getClaim(name = "email", jwtToken = jwtToken) + val name = getClaim(name = "name", jwtToken = jwtToken).orElse(description) val consumerId = if(APIUtil.checkIfStringIsUUID(azp.getOrElse(""))) azp else Some(s"{$azp}_${APIUtil.generateUUID()}") Consumers.consumers.vend.getOrCreateConsumer( consumerId = consumerId, // Use azp as consumer id if it is uuid value @@ -679,7 +689,7 @@ object OAuth2Login extends RestHelper with MdcLoggable { val sourceOfTruth = APIUtil.getPropsAsBoolValue(nameOfProperty = "oauth2.keycloak.source_of_truth", defaultValue = false) // Consumers allowed to use the source of truth feature val resourceAccessName = APIUtil.getPropsValue(nameOfProperty = "oauth2.keycloak.resource_access_key_name_to_trust", "open-bank-project") - val consumerId = getClaim(name = "azp", idToken = token).getOrElse("") + val consumerId = getClaim(name = "azp", jwtToken = token).getOrElse("") if(sourceOfTruth) { logger.debug("Extracting roles from Access Token") import net.liftweb.json._ diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApi.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApi.scala index f46477ee22..f968518725 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApi.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApi.scala @@ -127,10 +127,18 @@ or * access method is generally applicable, but further authorisation processes (ibanChecker, callContext) <- NewStyle.function.validateAndCheckIbanNumber(toAccountIban, callContext) _ <- Helper.booleanToFuture(invalidIban, cc=callContext) { ibanChecker.isValid == true } (_, callContext) <- NewStyle.function.getToBankAccountByIban(toAccountIban, callContext) + // Check payment status and determine if cancellation is allowed + currentStatus = transactionRequest.status.toUpperCase() + mappedStatus = mapTransactionStatus(currentStatus) (canBeCancelled, _, startSca) <- transactionRequestTypes match { case TransactionRequestTypes.SEPA_CREDIT_TRANSFERS => { - transactionRequest.status.toUpperCase() match { - case TransactionStatus.ACCP.code => + currentStatus match { + case TransactionStatus.RCVD.code | "INITIATED" => + // INITIATED status (maps to RCVD externally) - direct cancellation + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLED.toString, callContext) + Future(true, callContext, Some(false)) + case TransactionStatus.ACCP.code | "COMPLETED" => + // ACCP status - may require SCA for cancellation NewStyle.function.cancelPaymentV400(TransactionId(transactionRequest.transaction_ids), callContext) map { x => x._1 match { case CancelPayment(true, Some(startSca)) if startSca == true => @@ -143,15 +151,33 @@ or * access method is generally applicable, but further authorisation processes (false, x._2, Some(false)) } } - case "INITIATED" => - NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLED.toString, callContext) - Future(true, callContext, Some(false)) - case "CANCELLED" => + case TransactionStatus.PDNG.code | "PENDING" => + // PENDING status (maps to PDNG externally) - may require SCA + NewStyle.function.cancelPaymentV400(TransactionId(transactionRequest.transaction_ids), callContext) map { + x => x._1 match { + case CancelPayment(true, Some(startSca)) if startSca == true => + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLATION_PENDING.toString, callContext) + (true, x._2, Some(startSca)) + case CancelPayment(true, Some(startSca)) if startSca == false => + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLED.toString, callContext) + (true, x._2, Some(startSca)) + case CancelPayment(false, _) => + (false, x._2, Some(false)) + } + } + case TransactionStatus.CANC.code | "CANCELLED" => + // Already cancelled - return success Future(true, callContext, Some(false)) + case _ => + // Other statuses cannot be cancelled + Future(false, callContext, Some(false)) } } } - _ <- Helper.booleanToFuture(failMsg= TransactionRequestCannotBeCancelled, cc=callContext) { canBeCancelled == true } + _ <- Helper.booleanToFuture( + failMsg = s"$TransactionRequestCannotBeCancelled Payment status: $mappedStatus. Only payments in RCVD, ACCP, PDNG, or CANC status can be cancelled.", + cc = callContext + ) { canBeCancelled == true } (updatedTransactionRequest, callContext) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) } yield { startSca.getOrElse(false) match { diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/model/TransactionStatus.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/model/TransactionStatus.scala index eb69ccc555..59288d6f21 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/model/TransactionStatus.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/model/TransactionStatus.scala @@ -99,6 +99,8 @@ object TransactionStatus extends ApiModel { status match { case "COMPLETED" => TransactionStatus.ACCP.code case "INITIATED" => TransactionStatus.RCVD.code + case "PENDING" => TransactionStatus.PDNG.code + case "CANCELLED" => TransactionStatus.CANC.code case other => other } } diff --git a/obp-api/src/main/scala/code/api/openidconnect.scala b/obp-api/src/main/scala/code/api/openidconnect.scala index bdef460dc5..87f30b6253 100644 --- a/obp-api/src/main/scala/code/api/openidconnect.scala +++ b/obp-api/src/main/scala/code/api/openidconnect.scala @@ -215,7 +215,8 @@ object OpenIdConnect extends OBPRestHelper with MdcLoggable { private def getOrCreateResourceUser(idToken: String): Box[User] = { val uniqueIdGivenByProvider = JwtUtil.getSubject(idToken) val preferredUsername = JwtUtil.getOptionalClaim("preferred_username", idToken) - val provider = Hydra.resolveProvider(idToken) + // Try to get provider from token first, fallback to Hydra resolver + val provider = JwtUtil.getProvider(idToken).getOrElse(Hydra.resolveProvider(idToken)) val providerId = preferredUsername.orElse(uniqueIdGivenByProvider) Users.users.vend.getUserByProviderId(provider = provider, idGivenByProvider = providerId.getOrElse("")).or { // Find a user Users.users.vend.createResourceUser( // Otherwise create a new one diff --git a/obp-api/src/main/scala/code/api/util/JwtUtil.scala b/obp-api/src/main/scala/code/api/util/JwtUtil.scala index 8a07f724ca..33b1edecf4 100644 --- a/obp-api/src/main/scala/code/api/util/JwtUtil.scala +++ b/obp-api/src/main/scala/code/api/util/JwtUtil.scala @@ -182,6 +182,15 @@ object JwtUtil extends MdcLoggable { } } + /** + * Get the provider claim from the JWT token + * @param jwtToken JSON Web Token (JWT) as a String value + * @return The provider value or None if not available + */ + def getProvider(jwtToken: String): Option[String] = { + getOptionalClaim("provider", jwtToken) + } + /** * The Issuer Identifier for the Issuer of the response. * Get the value of the "iss" claim, or None if it's not available. diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index b9ee184e26..4be9a1723c 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -2450,7 +2450,23 @@ object LocalMappedConnector extends Connector with MdcLoggable { override def cancelPaymentV400(transactionId: TransactionId, callContext: Option[CallContext]): OBPReturnType[Box[CancelPayment]] = Future { - (Full(CancelPayment(true, Some(true))), callContext) + // Get transaction to determine if SCA is needed based on amount + val transaction = MappedTransaction.find(By(MappedTransaction.transactionId, transactionId.value)) + + val startSca = transaction match { + case Full(t) => + // Decide based on amount (similar to real CBS logic) + // Small amounts (<=100) don't need SCA, large amounts (>100) do + // Convert from smallest currency unit (cents) to actual decimal amount + val amount = Helper.smallestCurrencyUnitToBigDecimal(t.amount.get, t.currency.get).abs + val threshold = 100 + Some(amount > threshold) + case _ => + // If transaction not found, default to no SCA required + Some(false) + } + + (Full(CancelPayment(canBeCancelled = true, startSca = startSca)), callContext) } override def saveTransactionRequestStatusImpl(transactionRequestId: TransactionRequestId, status: String, callContext: Option[CallContext]): OBPReturnType[Box[Boolean]] = diff --git a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnectionPool.scala b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnectionPool.scala index 5e52d92271..54cfb6d898 100644 --- a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnectionPool.scala +++ b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnectionPool.scala @@ -27,7 +27,7 @@ class RabbitMQConnectionFactory extends BasePooledObjectFactory[Connection] { } catch { case e: Throwable => throw new RuntimeException(s"${ErrorMessages.UnknownError}, " + s"you set `rabbitmq.use.ssl = true`, but do not provide proper props for it, OBP can not set up ssl for rabbitMq. " + - s"Please check the rabbitmq ssl settings:`keystore.path`, `keystore.password` and `truststore.path` . Exception details: $e") + s"Please check the rabbitmq ssl settings: `truststore.path` is mandatory, `keystore.path` and `keystore.password` are optional for standard TLS. Exception details: $e") } } diff --git a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQUtils.scala b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQUtils.scala index d321379910..51de1848bd 100644 --- a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQUtils.scala +++ b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQUtils.scala @@ -32,10 +32,10 @@ object RabbitMQUtils extends MdcLoggable{ val (keystorePath, keystorePassword, truststorePath, truststorePassword) = if (APIUtil.getPropsAsBoolValue("rabbitmq.use.ssl", false)) { ( - APIUtil.getPropsValue("keystore.path").openOrThrowException("mandatory property keystore.path is missing!"), + APIUtil.getPropsValue("keystore.path").getOrElse(""), APIUtil.getPropsValue("keystore.password").getOrElse(APIUtil.initPasswd), APIUtil.getPropsValue("truststore.path").openOrThrowException("mandatory property truststore.path is missing!"), - APIUtil.getPropsValue("keystore.password").getOrElse(APIUtil.initPasswd) + APIUtil.getPropsValue("truststore.password").getOrElse(APIUtil.initPasswd) ) }else{ ("", APIUtil.initPasswd,"",APIUtil.initPasswd) @@ -163,28 +163,41 @@ object RabbitMQUtils extends MdcLoggable{ truststorePath: String, truststorePassword: String ): SSLContext = { - // Load client keystore - val keyStore = KeyStore.getInstance(KeyStore.getDefaultType) - val keystoreFile = new FileInputStream(keystorePath) - keyStore.load(keystoreFile, keystorePassword.toCharArray) - keystoreFile.close() - // Set up KeyManagerFactory for client certificates - val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm) - kmf.init(keyStore, keystorePassword.toCharArray) - - // Load truststore for CA certificates - val trustStore = KeyStore.getInstance(KeyStore.getDefaultType) - val truststoreFile = new FileInputStream(truststorePath) - trustStore.load(truststoreFile, truststorePassword.toCharArray) - truststoreFile.close() - - // Set up TrustManagerFactory for CA certificates - val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm) - tmf.init(trustStore) - - // Initialize SSLContext + // Load client keystore (optional for standard TLS) + val keyManagers = if (keystorePath.nonEmpty) { + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType) + val keystoreFile = new FileInputStream(keystorePath) + try { + keyStore.load(keystoreFile, keystorePassword.toCharArray) + } finally { + keystoreFile.close() + } + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm) + kmf.init(keyStore, keystorePassword.toCharArray) + kmf.getKeyManagers + } else { + null + } + + // Load truststore for CA certificates (required) + val trustManagers = if (truststorePath.nonEmpty) { + val trustStore = KeyStore.getInstance(KeyStore.getDefaultType) + val truststoreFile = new FileInputStream(truststorePath) + try { + trustStore.load(truststoreFile, truststorePassword.toCharArray) + } finally { + truststoreFile.close() + } + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm) + tmf.init(trustStore) + tmf.getTrustManagers + } else { + null + } + + // Initialize SSLContext with optional client authentication val sslContext = SSLContext.getInstance("TLSv1.3") - sslContext.init(kmf.getKeyManagers, tmf.getTrustManagers, null) + sslContext.init(keyManagers, trustManagers, null) sslContext } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala index ae8f100787..9e0ae6afa0 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala @@ -2,7 +2,7 @@ package code.api.berlin.group.v1_3 import code.api.BerlinGroup.ScaStatus import code.api.Constant.SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID -import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{CancellationJsonV13, ErrorMessagesBG, InitiatePaymentResponseJson, StartPaymentAuthorisationJson} +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{CancelPaymentResponseJson, CancellationJsonV13, ErrorMessagesBG, InitiatePaymentResponseJson, StartPaymentAuthorisationJson} import code.api.berlin.group.v1_3.model.{ScaStatusResponse, TransactionStatus, UpdatePsuAuthenticationResponse} import code.api.builder.PaymentInitiationServicePISApi.APIMethods_PaymentInitiationServicePISApi import code.api.util.APIUtil.OAuth._ @@ -454,19 +454,96 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with } } + feature(s"test the BG v1.3 ${cancelPayment.name} - Error Scenarios") { + scenario(s"${cancelPayment.name} Failed Case - Invalid PaymentId", BerlinGroupV1_3, PIS, cancelPayment) { + + When("Try to cancel payment with invalid paymentId") + val requestDelete = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / "INVALID_PAYMENT_ID").DELETE <@ (user1) + val responseDelete: APIResponse = makeDeleteRequest(requestDelete) + + Then("We should get a 400 Bad Request") + responseDelete.code should equal(400) + + And("Error message should indicate invalid transaction request ID") + val errorMessages = responseDelete.body.extract[ErrorMessagesBG] + errorMessages.tppMessages.head.text should startWith (InvalidTransactionRequestId) + } + + scenario(s"${cancelPayment.name} Failed Case - Payment Not Found", BerlinGroupV1_3, PIS, cancelPayment) { + + When("Try to cancel non-existent payment") + val nonExistentPaymentId = "00000000-0000-0000-0000-000000000000" + val requestDelete = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / nonExistentPaymentId).DELETE <@ (user1) + val responseDelete: APIResponse = makeDeleteRequest(requestDelete) + + Then("We should get a 400 or 404") + responseDelete.code should (equal(400) or equal(404)) + + And("Error message should indicate payment not found") + val errorMessages = responseDelete.body.extract[ErrorMessagesBG] + errorMessages.tppMessages.head.text should (include("not found") or include("not exist") or startWith(InvalidTransactionRequestId)) + } + + scenario(s"${cancelPayment.name} Failed Case - Cannot Cancel Completed Payment", BerlinGroupV1_3, PIS, cancelPayment) { + + val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) + val ibanFrom = accountsRoutingIban.head.accountRouting.address + val ibanTo = accountsRoutingIban.last.accountRouting.address + + val initiatePaymentJson = + s"""{ + | "debtorAccount": { + | "iban": "${ibanFrom}" + | }, + |"instructedAmount": { + | "currency": "EUR", + | "amount": "50" + |}, + |"creditorAccount": { + | "iban": "${ibanTo}" + |}, + |"creditorName": "70charname" + }""".stripMargin + + grantAccountAccess(accountsRoutingIban.head) + + When("Create and complete a payment") + val requestInitiatePaymentJson = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (user1) + val responseInitiatePaymentJson: APIResponse = makePostRequest(requestInitiatePaymentJson, initiatePaymentJson) + responseInitiatePaymentJson.code should equal(201) + val paymentResponseInitiatePaymentJson = responseInitiatePaymentJson.body.extract[InitiatePaymentResponseJson] + val paymentId = paymentResponseInitiatePaymentJson.paymentId + + // Note: In real scenario, payment would be completed by backend process + // For this test, we assume the payment status prevents cancellation + + Then("Try to cancel the payment") + val requestDelete = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / paymentId).DELETE <@ (user1) + val responseDelete: APIResponse = makeDeleteRequest(requestDelete) + + Then("If payment cannot be cancelled, we should get 400 or 405") + // Note: Response depends on actual payment status + // 202/204 = can cancel, 400/405 = cannot cancel + responseDelete.code should (equal(202) or equal(204) or equal(400) or equal(405)) + + And("If error, message should indicate payment cannot be cancelled") + if (responseDelete.code == 400 || responseDelete.code == 405) { + val errorMessages = responseDelete.body.extract[ErrorMessagesBG] + errorMessages.tppMessages.head.text should ( + include("cannot be cancelled") or + include("CANCELLATION_INVALID") or + startWith(TransactionRequestCannotBeCancelled) + ) + } + } + } + feature(s"test the BG v1.3 ${startPaymentInitiationCancellationAuthorisationTransactionAuthorisation.name} " + s"and ${getPaymentInitiationCancellationAuthorisationInformation.name} " + s"and ${getPaymentCancellationScaStatus.name}" + s"and ${updatePaymentCancellationPsuDataTransactionAuthorisation.name}") { - scenario(s"${startPaymentInitiationCancellationAuthorisationTransactionAuthorisation.name} Failed Case - Wrong PaymentId", BerlinGroupV1_3, PIS, startPaymentInitiationCancellationAuthorisationTransactionAuthorisation) { - - val requestPost = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / "PAYMENT_ID" / "cancellation-authorisations").POST <@ (user1) - val response: APIResponse = makePostRequest(requestPost, """{"scaAuthenticationData":""}""") - Then("We should get a 400 ") - response.code should equal(400) - response.body.extract[ErrorMessagesBG].tppMessages.head.text should startWith (InvalidTransactionRequestId) - } - scenario(s"Successful Case ", BerlinGroupV1_3, PIS) { + + scenario(s"Successful Case - Cancel payment with SCA (HTTP 202)", BerlinGroupV1_3, PIS, cancelPayment) { val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) val ibanFrom = accountsRoutingIban.head.accountRouting.address @@ -498,11 +575,15 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with val paymentId = paymentResponseInitiatePaymentJson.paymentId - Then(s"we test the ${cancelPayment.name}") + Then(s"we test the ${cancelPayment.name} - Scenario A: Cancel ACCP payment requiring SCA") val requestDelete = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / paymentId).DELETE <@ (user1) val responseDelete: APIResponse = makeDeleteRequest(requestDelete) - Then("We should get a 202") + Then("We should get a 202 Accepted (SCA required)") responseDelete.code should equal(202) + And("Response should contain transactionStatus ACTC") + val cancelResponse = responseDelete.body.extract[CancelPaymentResponseJson] + cancelResponse.transactionStatus should be (TransactionStatus.ACTC.code) + And("Response should contain startAuthorisation link") Then(s"we test the ${startPaymentInitiationCancellationAuthorisationTransactionAuthorisation.name}") val requestPost = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / paymentId / "cancellation-authorisations").POST <@ (user1) @@ -537,6 +618,54 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with responseUpdatePaymentCancellationPsuData.body.extract[ScaStatusResponse].scaStatus should be("finalised") } + + scenario(s"Successful Case - Direct cancel payment without SCA (HTTP 204)", BerlinGroupV1_3, PIS, cancelPayment) { + + val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) + val ibanFrom = accountsRoutingIban.head.accountRouting.address + val ibanTo = accountsRoutingIban.last.accountRouting.address + + val initiatePaymentJson = + s"""{ + | "debtorAccount": { + | "iban": "${ibanFrom}" + | }, + |"instructedAmount": { + | "currency": "EUR", + | "amount": "10" + |}, + |"creditorAccount": { + | "iban": "${ibanTo}" + |}, + |"creditorName": "70charname" + }""".stripMargin + + grantAccountAccess(accountsRoutingIban.head) + + When("Create a payment in INITIATED status (small amount)") + val requestInitiatePaymentJson = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (user1) + val responseInitiatePaymentJson: APIResponse = makePostRequest(requestInitiatePaymentJson, initiatePaymentJson) + Then("We should get a 201") + responseInitiatePaymentJson.code should equal(201) + val paymentResponseInitiatePaymentJson = responseInitiatePaymentJson.body.extract[InitiatePaymentResponseJson] + + val paymentId = paymentResponseInitiatePaymentJson.paymentId + + Then(s"we test the ${cancelPayment.name} - Scenario B: Direct cancel INITIATED/RCVD payment without SCA") + val requestDelete = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / paymentId).DELETE <@ (user1) + val responseDelete: APIResponse = makeDeleteRequest(requestDelete) + Then("We should get a 204 No Content (direct cancellation)") + responseDelete.code should equal(204) + And("Response body should be empty") + responseDelete.body.toString should be ("JNothing") + + Then("Verify payment status is CANC") + val requestGetStatus = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / paymentId / "status").GET <@ (user1) + val responseGetStatus: APIResponse = makeGetRequest(requestGetStatus) + responseGetStatus.code should equal(200) + val statusResponse = (responseGetStatus.body \ "transactionStatus").extract[String] + statusResponse should be (TransactionStatus.CANC.code) + } } feature(s"test the BG v1.3 ${updatePaymentCancellationPsuDataUpdatePsuAuthentication.name}" ) {