mirror of
https://github.com/OpenBankProject/OBP-API.git
synced 2026-02-06 16:36:54 +00:00
commit
e75e190bf7
@ -0,0 +1,65 @@
|
||||
# OAuth 2.0 Client Credentials Flow Manual
|
||||
|
||||
## Overview
|
||||
OAuth 2.0 Client Credentials Flow is used when a client application (such as a backend service) needs to authenticate and request access to resources without user interaction. This flow is typically used for machine-to-machine (M2M) authentication.
|
||||
|
||||
## Prerequisites / Assumptions
|
||||
Before making requests, ensure you have:
|
||||
- A valid **client_id** and **client_secret**.
|
||||
- This example assumes the authorization server (Keycloak) is running on **localhost:7070**. Replace this with the actual auth server URL.
|
||||
- A realm needs to been configured (e.g. 'master') and respective endpoint available: `/realms/master/protocol/openid-connect/token`.
|
||||
|
||||
## 1. Requesting an Access Token
|
||||
To obtain an access token, send a **POST** request to the token endpoint with the following details.
|
||||
|
||||
### **Request**
|
||||
```
|
||||
POST /realms/master/protocol/openid-connect/token HTTP/1.1
|
||||
Host: localhost:7070
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Authorization: Basic Og==
|
||||
Content-Length: 104
|
||||
|
||||
client_id=<client_id>&client_secret=<client_secret>&grant_type=client_credentials
|
||||
```
|
||||
|
||||
### **Explanation of Parameters**
|
||||
| Parameter | Description |
|
||||
|------------------|-------------|
|
||||
| `client_id` | The unique identifier for your client application. |
|
||||
| `client_secret` | The secret key assigned to your client. |
|
||||
| `grant_type` | Must be set to `client_credentials` to indicate this authentication flow. |
|
||||
|
||||
### **Example cURL Command**
|
||||
```sh
|
||||
curl -X POST "http://localhost:7070/realms/master/protocol/openid-connect/token" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-u "open-bank-project:WWJ04UzMhWmLEqW2KIgBHwD4UNEotzXz" \
|
||||
-d "grant_type=client_credentials"
|
||||
```
|
||||
|
||||
## 2. Expected Response
|
||||
A successful request will return a JSON response containing the access token:
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
}
|
||||
```
|
||||
|
||||
### **Response Fields**
|
||||
| Field | Description |
|
||||
|----------------|-------------|
|
||||
| `access_token` | The token required to authenticate API requests. |
|
||||
| `token_type` | Usually `Bearer`, meaning it should be included in the Authorization header. |
|
||||
| `expires_in` | The token expiration time in seconds. |
|
||||
|
||||
## 3. Using the Access Token
|
||||
Once you obtain the access token, include it in the `Authorization` header of your subsequent API requests:
|
||||
|
||||
### **Example API Request with Token**
|
||||
```sh
|
||||
curl -X GET "http://localhost:7070/protected/resource" \
|
||||
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
```
|
||||
@ -165,6 +165,9 @@ jwt.use.ssl=false
|
||||
# Bypass TPP signature validation
|
||||
# bypass_tpp_signature_validation = false
|
||||
|
||||
## Reject Berlin Group Consents in status "received" after defined time
|
||||
# berlin_group_outdated_consents_interval = 5
|
||||
|
||||
|
||||
## Enable writing API metrics (which APIs are called) to RDBMS
|
||||
write_metrics=true
|
||||
|
||||
@ -107,7 +107,7 @@ import code.productfee.ProductFee
|
||||
import code.products.MappedProduct
|
||||
import code.ratelimiting.RateLimiting
|
||||
import code.regulatedentities.MappedRegulatedEntity
|
||||
import code.scheduler.{DataBaseCleanerScheduler, DatabaseDriverScheduler, JobScheduler, MetricsArchiveScheduler}
|
||||
import code.scheduler.{ConsentScheduler, DataBaseCleanerScheduler, DatabaseDriverScheduler, JobScheduler, MetricsArchiveScheduler}
|
||||
import code.scope.{MappedScope, MappedUserScope}
|
||||
import code.signingbaskets.{MappedSigningBasket, MappedSigningBasketConsent, MappedSigningBasketPayment}
|
||||
import code.snippet.{OAuthAuthorisation, OAuthWorkedThanks}
|
||||
@ -728,6 +728,8 @@ class Boot extends MdcLoggable {
|
||||
case Full(i) => DatabaseDriverScheduler.start(i)
|
||||
case _ => // Do not start it
|
||||
}
|
||||
ConsentScheduler.startAll()
|
||||
|
||||
|
||||
APIUtil.getPropsAsBoolValue("enable_metrics_scheduler", true) match {
|
||||
case true =>
|
||||
|
||||
@ -1281,9 +1281,9 @@ Maybe in a later version the access path will change.
|
||||
)
|
||||
consent <- challenge.scaStatus match {
|
||||
case Some(status) if status == StrongCustomerAuthenticationStatus.finalised => // finalised
|
||||
Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.VALID))
|
||||
Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.valid))
|
||||
case Some(status) if status == StrongCustomerAuthenticationStatus.failed => // failed
|
||||
Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.REJECTED))
|
||||
Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.rejected))
|
||||
case _ => // all other cases
|
||||
Future(Consents.consentProvider.vend.getConsentByConsentId(consentId))
|
||||
}
|
||||
|
||||
@ -548,7 +548,8 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats {
|
||||
_links = ConsentLinksV13(
|
||||
scaRedirect = Some(Href(s"$scaRedirectUrl")),
|
||||
status = Some(Href(s"/v1.3/consents/${consent.consentId}/status")),
|
||||
scaStatus = Some(Href(s"/v1.3/consents/${consent.consentId}/authorisations/AUTHORISATIONID")),
|
||||
// TODO Introduce a working link
|
||||
// scaStatus = Some(Href(s"/v1.3/consents/${consent.consentId}/authorisations/AUTHORISATIONID")),
|
||||
)
|
||||
)
|
||||
case Full("redirection_with_dedicated_start_of_authorization") =>
|
||||
|
||||
@ -732,7 +732,8 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
|
||||
callContextLight.map(_.url)
|
||||
else
|
||||
None
|
||||
val errorMessagesBG = ErrorMessagesBG(tppMessages = List(ErrorMessageBG(category = "ERROR", code = code.toString, path = path, text = message)))
|
||||
val codeText = BerlinGroupError.translateToBerlinGroupError(code.toString, message)
|
||||
val errorMessagesBG = ErrorMessagesBG(tppMessages = List(ErrorMessageBG(category = "ERROR", code = codeText, path = path, text = message)))
|
||||
Extraction.decompose(errorMessagesBG)
|
||||
} else {
|
||||
Extraction.decompose(ErrorMessage(message = message, code = code))
|
||||
|
||||
84
obp-api/src/main/scala/code/api/util/BerlinGroupError.scala
Normal file
84
obp-api/src/main/scala/code/api/util/BerlinGroupError.scala
Normal file
@ -0,0 +1,84 @@
|
||||
package code.api.util
|
||||
|
||||
object BerlinGroupError {
|
||||
|
||||
/*
|
||||
+---------------------------+---------------------------------------------+------------------------------------------------------------------------------------------------+---------------------------------+
|
||||
| Code | HTTP Code | Description | Endpoint Method |
|
||||
+---------------------------+---------------------------------------------+------------------------------------------------------------------------------------------------+---------------------------------+
|
||||
| FORMAT_ERROR | 400 | The format of certain fields in the request does not meet the requirements. | /consents, /accounts, /payments |
|
||||
| PARAMETER_NOT_CONSISTENT | 400 | The parameters sent by TPP are inconsistent (only for query parameters). | /consents, /accounts, /payments |
|
||||
| PARAMETER_NOT_SUPPORTED | 400 | The parameter is not supported by the ASPSP API. | /consents, /accounts |
|
||||
| SERVICE_INVALID | 400 (if payload) / 405 (if HTTP method) | The requested service is not valid for the requested resources. | /consents, /accounts, /payments |
|
||||
| RESOURCE_UNKNOWN | 400 (if payload) / 403 / 404 | The requested resource cannot be found with respect to the TPP. | /consents, /accounts, /payments |
|
||||
| RESOURCE_EXPIRED | 400 (if payload) / 403 | The requested resource has expired and is no longer accessible. | /consents, /accounts, /payments |
|
||||
| RESOURCE_BLOCKED | 400 | The requested resource cannot be accessed because it is blocked. | /consents, /accounts, /payments |
|
||||
| TIMESTAMP_INVALID | 400 | The time is not within an accepted period. | /consents, /accounts, /payments |
|
||||
| PERIOD_INVALID | 400 | The requested time period is out of range. | /consents, /accounts, /payments |
|
||||
| SCA_METHOD_UNKNOWN | 400 | The SCA method selected is unknown or cannot be compared by ASPSP with PSU. | /consents, /accounts, /payments |
|
||||
| CONSENT_UNKNOWN | 400 (if header) / 403 (if path) | Consent-ID cannot be found by ASPSP with respect to TPP. | /consents, /accounts, /payments |
|
||||
| SESSIONS_NOT_SUPPORTED | 400 | The combined service indicator cannot be used with this ASPSP. | /consents, /accounts, /payments |
|
||||
| PAYMENT_FAILED | 400 | The POST request for initiating the payment failed. | /payments |
|
||||
| EXECUTION_DATE_INVALID | 400 | The requested execution date is invalid for ASPSP. | /payments |
|
||||
| CERTIFICATE_INVALID | 401 | The signature certificate content is invalid. | /consents, /accounts, /payments |
|
||||
| ROLE_INVALID | 403 | The TPP does not have the necessary role. | /consents, /accounts |
|
||||
| CERTIFICATE_EXPIRED | 401 | The signature certificate has expired. | /consents, /accounts, /payments |
|
||||
| CERTIFICATE_BLOCKED | 401 | The signature certificate has been blocked. | /consents, /accounts, /payments |
|
||||
| CERTIFICATE_REVOKED | 401 | The signature certificate has been revoked. | /consents, /accounts, /payments |
|
||||
| CERTIFICATE_MISSING | 401 | The signature certificate was not available in the request but is required. | /consents, /accounts, /payments |
|
||||
| SIGNATURE_INVALID | 401 | The signature applied for TPP authentication is invalid. | /consents, /accounts, /payments |
|
||||
| SIGNATURE_MISSING | 401 | The signature applied for TPP authentication is missing. | /consents, /accounts, /payments |
|
||||
| CORPORATE_ID_INVALID | 401 | PSU-Corporate-ID cannot be found by ASPSP. | /consents, /accounts, /payments |
|
||||
| PSU_CREDENTIALS_INVALID | 401 | PSU-ID cannot be found by ASPSP, or it is blocked, or the password/OTP is incorrect. | /consents, /accounts, /payments |
|
||||
| CONSENT_INVALID | 401 | The consent created by TPP is not valid for the requested service/resource. | /consents, /accounts, /payments |
|
||||
| CONSENT_EXPIRED | 401 | The consent created by TPP has expired and needs to be renewed. | /consents, /accounts, /payments |
|
||||
| TOKEN_UNKNOWN | 401 | The OAuth2 token cannot be found by ASPSP with respect to TPP. | /consents, /accounts, /payments |
|
||||
| TOKEN_INVALID | 401 | The OAuth2 token is associated with the TPP but is not valid for the requested service. | /consents, /accounts, /payments |
|
||||
| TOKEN_EXPIRED | 401 | The OAuth2 token has expired and needs to be renewed. | /consents, /accounts, /payments |
|
||||
| SERVICE_BLOCKED | 403 | The service is not accessible to PSU due to a block by ASPSP. | /consents, /accounts, /payments |
|
||||
| PRODUCT_INVALID | 403 | The requested payment product is not available for PSU. | /payments |
|
||||
| PRODUCT_UNKNOWN | 404 | The requested payment product is not supported by ASPSP. | /payments |
|
||||
| CANCELLATION_INVALID | 405 | Payments cannot be cancelled due to a time limit or legal restrictions. | /payments |
|
||||
| REQUESTED_FORMATS_INVALID | 406 | The formats requested in the Accept header do not match the formats offered by ASPSP. | /consents, /accounts |
|
||||
| STATUS_INVALID | 409 | The requested resource does not allow additional authorizations. | /consents, /accounts, /payments |
|
||||
| ACCESS_EXCEEDED | 429 | Access to the account has exceeded the consented frequency per day. | /consents, /accounts |
|
||||
+---------------------------+---------------------------------------------+------------------------------------------------------------------------------------------------+---------------------------------+
|
||||
*/
|
||||
def translateToBerlinGroupError(code: String, message: String): String = {
|
||||
code match {
|
||||
case "401" if message.contains("OBP-20001") => "PSU_CREDENTIALS_INVALID"
|
||||
case "401" if message.contains("OBP-20201") => "PSU_CREDENTIALS_INVALID"
|
||||
case "401" if message.contains("OBP-20214") => "PSU_CREDENTIALS_INVALID"
|
||||
case "401" if message.contains("OBP-20013") => "PSU_CREDENTIALS_INVALID"
|
||||
case "401" if message.contains("OBP-20202") => "PSU_CREDENTIALS_INVALID"
|
||||
case "401" if message.contains("OBP-20203") => "PSU_CREDENTIALS_INVALID"
|
||||
case "401" if message.contains("OBP-20206") => "PSU_CREDENTIALS_INVALID"
|
||||
case "401" if message.contains("OBP-20207") => "PSU_CREDENTIALS_INVALID"
|
||||
|
||||
case "401" if message.contains("OBP-20204") => "TOKEN_EXPIRED"
|
||||
|
||||
case "401" if message.contains("OBP-35003") => "CONSENT_EXPIRED"
|
||||
|
||||
case "401" if message.contains("OBP-35004") => "CONSENT_INVALID"
|
||||
case "401" if message.contains("OBP-35015") => "CONSENT_INVALID"
|
||||
case "401" if message.contains("OBP-35017") => "CONSENT_INVALID"
|
||||
case "401" if message.contains("OBP-35019") => "CONSENT_INVALID"
|
||||
case "401" if message.contains("OBP-35018") => "CONSENT_INVALID"
|
||||
|
||||
case "401" if message.contains("OBP-20060") => "ROLE_INVALID"
|
||||
|
||||
case "400" if message.contains("OBP-35018") => "CONSENT_UNKNOWN"
|
||||
case "400" if message.contains("OBP-35001") => "CONSENT_UNKNOWN"
|
||||
|
||||
case "404" if message.contains("OBP-30076") => "RESOURCE_UNKNOWN"
|
||||
|
||||
case "400" if message.contains("OBP-10001") => "FORMAT_ERROR"
|
||||
case "400" if message.contains("OBP-20062") => "FORMAT_ERROR"
|
||||
case "400" if message.contains("OBP-20063") => "FORMAT_ERROR"
|
||||
|
||||
case "429" if message.contains("OBP-10018") => "ACCESS_EXCEEDED"
|
||||
case _ => code
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -202,7 +202,7 @@ object Consent extends MdcLoggable {
|
||||
val consentBox = Consents.consentProvider.vend.getConsentByConsentId(consent.jti)
|
||||
logger.debug(s"code.api.util.Consent.checkConsent.getConsentByConsentId: consentBox($consentBox)")
|
||||
val result = consentBox match {
|
||||
case Full(c) if c.mStatus.toString().toUpperCase == ConsentStatus.ACCEPTED.toString | c.mStatus.toString().toUpperCase() == ConsentStatus.VALID.toString =>
|
||||
case Full(c) if c.mStatus.toString().toUpperCase == ConsentStatus.ACCEPTED.toString | c.mStatus.toString().toLowerCase() == ConsentStatus.valid.toString =>
|
||||
verifyHmacSignedJwt(consentIdAsJwt, c) match {
|
||||
case true =>
|
||||
(System.currentTimeMillis / 1000) match {
|
||||
|
||||
@ -3514,10 +3514,8 @@ object Glossary extends MdcLoggable {
|
||||
}
|
||||
|
||||
private def getListOfFiles():List[File] = {
|
||||
val d = new File("src/main/docs/glossary").exists() match {
|
||||
case true => new File("src/main/docs/glossary")
|
||||
case false => new File("obp-api/src/main/docs/glossary")
|
||||
}
|
||||
val currentDir = new File(".").getCanonicalPath
|
||||
val d = new File(currentDir + "/obp-api/src/main/docs/glossary")
|
||||
if (d.exists && d.isDirectory) {
|
||||
d.listFiles.filter(_.isFile).filter(_.getName.endsWith(".md")).toList
|
||||
} else {
|
||||
|
||||
@ -191,7 +191,7 @@ object ConsentStatus extends Enumeration {
|
||||
type ConsentStatus = Value
|
||||
val INITIATED, ACCEPTED, REJECTED, rejected, REVOKED,
|
||||
// The following one only exist in case of BerlinGroup
|
||||
RECEIVED, received, VALID, valid, REVOKEDBYPSU, revokedByPsu, EXPIRED, expired, TERMINATEDBYTPP, terminatedByTpp,
|
||||
received, valid, REVOKEDBYPSU, revokedByPsu, EXPIRED, expired, TERMINATEDBYTPP, terminatedByTpp,
|
||||
//these added for UK Open Banking
|
||||
AUTHORISED, AWAITINGAUTHORISATION = Value
|
||||
}
|
||||
|
||||
@ -130,7 +130,7 @@ object MappedConsentProvider extends ConsentProvider {
|
||||
.create
|
||||
.mUserId(user.map(_.userId).getOrElse(null))
|
||||
.mConsumerId(consumer.map(_.consumerId.get).getOrElse(null))
|
||||
.mStatus(ConsentStatus.RECEIVED.toString)
|
||||
.mStatus(ConsentStatus.received.toString)
|
||||
.mRecurringIndicator(recurringIndicator)
|
||||
.mValidUntil(validUntil)
|
||||
.mFrequencyPerDay(frequencyPerDay)
|
||||
|
||||
73
obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala
Normal file
73
obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala
Normal file
@ -0,0 +1,73 @@
|
||||
package code.scheduler
|
||||
|
||||
import code.actorsystem.ObpLookupSystem
|
||||
import code.api.util.APIUtil
|
||||
import code.consent.{ConsentStatus, MappedConsent}
|
||||
import code.util.Helper.MdcLoggable
|
||||
import com.openbankproject.commons.util.ApiVersion
|
||||
import net.liftweb.mapper.{By, By_<}
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.{Calendar, Date}
|
||||
import scala.concurrent.duration._
|
||||
import scala.util.{Failure, Success, Try}
|
||||
|
||||
|
||||
object ConsentScheduler extends MdcLoggable {
|
||||
|
||||
private lazy val actorSystem = ObpLookupSystem.obpLookupSystem
|
||||
implicit lazy val executor = actorSystem.dispatcher
|
||||
private lazy val scheduler = actorSystem.scheduler
|
||||
|
||||
// Starts multiple scheduled tasks with different intervals
|
||||
def startAll(): Unit = {
|
||||
startTask(interval = 60, () => unfinishedBerlinGroupConsents()) // Runs every 60 sec
|
||||
}
|
||||
|
||||
// Generic method to schedule a task
|
||||
private def startTask(interval: Long, task: () => Unit): Unit = {
|
||||
scheduler.schedule(
|
||||
initialDelay = Duration(interval, TimeUnit.SECONDS),
|
||||
interval = Duration(interval, TimeUnit.SECONDS),
|
||||
runnable = new Runnable {
|
||||
def run(): Unit = task()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate the timestamp 5 minutes ago
|
||||
private val someMinutesAgo: Date = {
|
||||
val minutes = APIUtil.getPropsAsIntValue("berlin_group_outdated_consents_interval", 5)
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MINUTE, -minutes)
|
||||
cal.getTime
|
||||
}
|
||||
|
||||
private def unfinishedBerlinGroupConsents(): Unit = {
|
||||
Try {
|
||||
logger.debug("|---> Checking for outdated Berlin Group consents...")
|
||||
|
||||
val outdatedConsents = MappedConsent.findAll(
|
||||
By(MappedConsent.mStatus, ConsentStatus.received.toString),
|
||||
By(MappedConsent.mApiStandard, ApiVersion.berlinGroupV13.apiStandard),
|
||||
By_<(MappedConsent.updatedAt, someMinutesAgo)
|
||||
)
|
||||
|
||||
logger.debug(s"|---> Found ${outdatedConsents.size} outdated consents")
|
||||
|
||||
outdatedConsents.foreach { consent =>
|
||||
Try {
|
||||
consent.mStatus(ConsentStatus.rejected.toString).save
|
||||
logger.warn(s"|---> Changed status to ${ConsentStatus.rejected.toString} for consent ID: ${consent.id}")
|
||||
} match {
|
||||
case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex)
|
||||
case Success(_) => // Already logged
|
||||
}
|
||||
}
|
||||
} match {
|
||||
case Failure(ex) => logger.error("Error in unfinishedBerlinGroupConsents!", ex)
|
||||
case Success(_) => logger.debug("|---> Task executed successfully")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -284,11 +284,26 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
|
||||
</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>
|
||||
<p style="padding-left: 20px">
|
||||
Read account details
|
||||
<div style={if (!updateConsentPayloadValue.is) "padding-left: 40px; display: block;" else "display: none;"}>
|
||||
{scala.xml.Unparsed(canReadAccountsIbans.map(iban => s"- $iban").mkString("<br/>"))}
|
||||
</div>
|
||||
</p>
|
||||
<p style={if (accessBalancesDefinedVar.is) "padding-left: 20px;" else "padding-left: 20px; display: none;"}>
|
||||
Read account balances
|
||||
<div style={if (!updateConsentPayloadValue.is) "padding-left: 40px; display: block;" else "display: none;"}>
|
||||
{scala.xml.Unparsed(canReadBalancesIbans.map(iban => s"- $iban").mkString("<br/>"))}
|
||||
</div>
|
||||
</p>
|
||||
<p style={if (accessTransactionsDefinedVar.is) "padding-left: 20px;" else "padding-left: 20px; display: none;"}>
|
||||
Read transactions
|
||||
<div style={if (!updateConsentPayloadValue.is) "padding-left: 40px; display: block;" else "display: none;"}>
|
||||
{scala.xml.Unparsed(canReadTransactionsIbans.map(iban => s"- $iban").mkString("<br/>"))}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div style={if (updateConsentPayloadValue.is) "display: block;" else "display: none;"}>
|
||||
<p><strong>Accounts</strong>:</p>
|
||||
<div style="padding-left: 20px">
|
||||
{generateCheckboxes("canReadAccountsIbans", userIbans.toList, selectedAccountsIbansValue.is, selectedAccountsIbansValue, ibansFromGetConsentResponseJson)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user