forked from shaba/openuds
Merge remote-tracking branch 'origin/v3.5-mfa'
This commit is contained in:
commit
8b8bf7a321
@ -43,6 +43,7 @@ from uds.core.auths import exceptions
|
|||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from uds.core.environment import Environment
|
from uds.core.environment import Environment
|
||||||
|
from uds.core.util.request import ExtendedHttpRequest
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -132,7 +133,7 @@ class MFA(Module):
|
|||||||
"""
|
"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def sendCode(self, userId: str, username: str, identifier: str, code: str) -> 'MFA.RESULT':
|
def sendCode(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, code: str) -> 'MFA.RESULT':
|
||||||
"""
|
"""
|
||||||
This method will be invoked from "process" method, to send the MFA code to the user.
|
This method will be invoked from "process" method, to send the MFA code to the user.
|
||||||
If returns MFA.RESULT.VALID, the MFA code was sent.
|
If returns MFA.RESULT.VALID, the MFA code was sent.
|
||||||
@ -142,7 +143,7 @@ class MFA(Module):
|
|||||||
|
|
||||||
raise NotImplementedError('sendCode method not implemented')
|
raise NotImplementedError('sendCode method not implemented')
|
||||||
|
|
||||||
def process(self, userId: str, username: str, identifier: str, validity: typing.Optional[int] = None) -> 'MFA.RESULT':
|
def process(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, validity: typing.Optional[int] = None) -> 'MFA.RESULT':
|
||||||
"""
|
"""
|
||||||
This method will be invoked from the MFA form, to send the MFA code to the user.
|
This method will be invoked from the MFA form, to send the MFA code to the user.
|
||||||
The identifier where to send the code, will be obtained from "mfaIdentifier" method.
|
The identifier where to send the code, will be obtained from "mfaIdentifier" method.
|
||||||
@ -171,7 +172,7 @@ class MFA(Module):
|
|||||||
# Store the code in the database, own storage space
|
# Store the code in the database, own storage space
|
||||||
self.storage.putPickle(userId, (getSqlDatetime(), code))
|
self.storage.putPickle(userId, (getSqlDatetime(), code))
|
||||||
# Send the code to the user
|
# Send the code to the user
|
||||||
return self.sendCode(userId, username, identifier, code)
|
return self.sendCode(request, userId, username, identifier, code)
|
||||||
|
|
||||||
|
|
||||||
def validate(self, userId: str, username: str, identifier: str, code: str, validity: typing.Optional[int] = None) -> None:
|
def validate(self, userId: str, username: str, identifier: str, code: str, validity: typing.Optional[int] = None) -> None:
|
||||||
|
@ -128,6 +128,8 @@ class gui:
|
|||||||
Helper to convert from array of strings to the same dict used in choice,
|
Helper to convert from array of strings to the same dict used in choice,
|
||||||
multichoice, ..
|
multichoice, ..
|
||||||
"""
|
"""
|
||||||
|
if not vals:
|
||||||
|
return []
|
||||||
if isinstance(vals, (list, tuple)):
|
if isinstance(vals, (list, tuple)):
|
||||||
return [{'id': v, 'text': v} for v in vals]
|
return [{'id': v, 'text': v} for v in vals]
|
||||||
|
|
||||||
@ -1003,6 +1005,12 @@ class UserInterface(metaclass=UserInterfaceType):
|
|||||||
of this posibility in a near version...
|
of this posibility in a near version...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def initClassGui(cls) -> None:
|
||||||
|
"""
|
||||||
|
This method is used to initialize the gui fields of the class.
|
||||||
|
"""
|
||||||
|
|
||||||
def valuesDict(self) -> gui.ValuesDictType:
|
def valuesDict(self) -> gui.ValuesDictType:
|
||||||
"""
|
"""
|
||||||
Returns own data needed for user interaction as a dict of key-names ->
|
Returns own data needed for user interaction as a dict of key-names ->
|
||||||
@ -1163,6 +1171,8 @@ class UserInterface(metaclass=UserInterfaceType):
|
|||||||
if obj:
|
if obj:
|
||||||
obj.initGui() # We give the "oportunity" to fill necesary theGui data before providing it to client
|
obj.initGui() # We give the "oportunity" to fill necesary theGui data before providing it to client
|
||||||
theGui = obj
|
theGui = obj
|
||||||
|
else:
|
||||||
|
cls.initClassGui() # We give the "oportunity" to fill necesary theGui data before providing it to client
|
||||||
|
|
||||||
res: typing.List[typing.MutableMapping[str, typing.Any]] = [
|
res: typing.List[typing.MutableMapping[str, typing.Any]] = [
|
||||||
{'name': key, 'gui': val.guiDescription(), 'value': ''}
|
{'name': key, 'gui': val.guiDescription(), 'value': ''}
|
||||||
|
@ -6,11 +6,14 @@ from django.utils.translation import gettext_noop as _, gettext
|
|||||||
import requests
|
import requests
|
||||||
import requests.auth
|
import requests.auth
|
||||||
|
|
||||||
|
from uds import models
|
||||||
from uds.core import mfas
|
from uds.core import mfas
|
||||||
from uds.core.ui import gui
|
from uds.core.ui import gui
|
||||||
|
from uds.core.util import net
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from uds.core.module import Module
|
from uds.core.module import Module
|
||||||
|
from uds.core.util.request import ExtendedHttpRequest
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -21,7 +24,7 @@ class SMSMFA(mfas.MFA):
|
|||||||
typeDescription = _('Simple SMS sending MFA using HTTP')
|
typeDescription = _('Simple SMS sending MFA using HTTP')
|
||||||
iconFile = 'sms.png'
|
iconFile = 'sms.png'
|
||||||
|
|
||||||
smsSendingUrl = gui.TextField(
|
sendingUrl = gui.TextField(
|
||||||
length=128,
|
length=128,
|
||||||
label=_('URL pattern for SMS sending'),
|
label=_('URL pattern for SMS sending'),
|
||||||
order=1,
|
order=1,
|
||||||
@ -48,7 +51,7 @@ class SMSMFA(mfas.MFA):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
smsSendingMethod = gui.ChoiceField(
|
sendingMethod = gui.ChoiceField(
|
||||||
label=_('SMS sending method'),
|
label=_('SMS sending method'),
|
||||||
order=3,
|
order=3,
|
||||||
tooltip=_('Method for sending SMS'),
|
tooltip=_('Method for sending SMS'),
|
||||||
@ -57,7 +60,7 @@ class SMSMFA(mfas.MFA):
|
|||||||
values=('GET', 'POST', 'PUT'),
|
values=('GET', 'POST', 'PUT'),
|
||||||
)
|
)
|
||||||
|
|
||||||
smsHeadersParameters = gui.TextField(
|
headersParameters = gui.TextField(
|
||||||
length=4096,
|
length=4096,
|
||||||
multiline=4,
|
multiline=4,
|
||||||
label=_('Headers for SMS requests'),
|
label=_('Headers for SMS requests'),
|
||||||
@ -75,7 +78,7 @@ class SMSMFA(mfas.MFA):
|
|||||||
tab=_('HTTP Server'),
|
tab=_('HTTP Server'),
|
||||||
)
|
)
|
||||||
|
|
||||||
smsSendingParameters = gui.TextField(
|
sendingParameters = gui.TextField(
|
||||||
length=4096,
|
length=4096,
|
||||||
multiline=5,
|
multiline=5,
|
||||||
label=_('Parameters for SMS POST/PUT sending'),
|
label=_('Parameters for SMS POST/PUT sending'),
|
||||||
@ -92,7 +95,7 @@ class SMSMFA(mfas.MFA):
|
|||||||
tab=_('HTTP Server'),
|
tab=_('HTTP Server'),
|
||||||
)
|
)
|
||||||
|
|
||||||
smsEncoding = gui.ChoiceField(
|
encoding = gui.ChoiceField(
|
||||||
label=_('SMS encoding'),
|
label=_('SMS encoding'),
|
||||||
defaultValue='utf-8',
|
defaultValue='utf-8',
|
||||||
order=5,
|
order=5,
|
||||||
@ -102,7 +105,7 @@ class SMSMFA(mfas.MFA):
|
|||||||
values=('utf-8', 'iso-8859-1'),
|
values=('utf-8', 'iso-8859-1'),
|
||||||
)
|
)
|
||||||
|
|
||||||
smsAuthenticationMethod = gui.ChoiceField(
|
authenticationMethod = gui.ChoiceField(
|
||||||
label=_('SMS authentication method'),
|
label=_('SMS authentication method'),
|
||||||
order=20,
|
order=20,
|
||||||
tooltip=_('Method for sending SMS'),
|
tooltip=_('Method for sending SMS'),
|
||||||
@ -115,7 +118,7 @@ class SMSMFA(mfas.MFA):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
smsAuthenticationUserOrToken = gui.TextField(
|
authenticationUserOrToken = gui.TextField(
|
||||||
length=256,
|
length=256,
|
||||||
label=_('SMS authentication user or token'),
|
label=_('SMS authentication user or token'),
|
||||||
order=21,
|
order=21,
|
||||||
@ -124,7 +127,7 @@ class SMSMFA(mfas.MFA):
|
|||||||
tab=_('HTTP Authentication'),
|
tab=_('HTTP Authentication'),
|
||||||
)
|
)
|
||||||
|
|
||||||
smsAuthenticationPassword = gui.PasswordField(
|
authenticationPassword = gui.PasswordField(
|
||||||
length=256,
|
length=256,
|
||||||
label=_('SMS authentication password'),
|
label=_('SMS authentication password'),
|
||||||
order=22,
|
order=22,
|
||||||
@ -133,7 +136,7 @@ class SMSMFA(mfas.MFA):
|
|||||||
tab=_('HTTP Authentication'),
|
tab=_('HTTP Authentication'),
|
||||||
)
|
)
|
||||||
|
|
||||||
smsResponseOkRegex = gui.TextField(
|
responseOkRegex = gui.TextField(
|
||||||
length=256,
|
length=256,
|
||||||
label=_('SMS response OK regex'),
|
label=_('SMS response OK regex'),
|
||||||
order=30,
|
order=30,
|
||||||
@ -144,7 +147,7 @@ class SMSMFA(mfas.MFA):
|
|||||||
tab=_('HTTP Response'),
|
tab=_('HTTP Response'),
|
||||||
)
|
)
|
||||||
|
|
||||||
smsResponseErrorAction = gui.ChoiceField(
|
responseErrorAction = gui.ChoiceField(
|
||||||
label=_('SMS response error action'),
|
label=_('SMS response error action'),
|
||||||
order=31,
|
order=31,
|
||||||
defaultValue='0',
|
defaultValue='0',
|
||||||
@ -154,14 +157,34 @@ class SMSMFA(mfas.MFA):
|
|||||||
values={
|
values={
|
||||||
'0': _('Allow user log in without MFA'),
|
'0': _('Allow user log in without MFA'),
|
||||||
'1': _('Deny user log in'),
|
'1': _('Deny user log in'),
|
||||||
|
'2': _('Allow user to log in if it IP is in the networks list'),
|
||||||
|
'3': _('Deny user to log in if it IP is in the networks list'),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
networks = gui.MultiChoiceField(
|
||||||
|
label=_('SMS networks'),
|
||||||
|
rdonly=False,
|
||||||
|
rows=5,
|
||||||
|
order=32,
|
||||||
|
tooltip=_('Networks for SMS authentication'),
|
||||||
|
required=True,
|
||||||
|
tab=_('HTTP Response'),
|
||||||
|
)
|
||||||
|
|
||||||
def initialize(self, values: 'Module.ValuesType') -> None:
|
def initialize(self, values: 'Module.ValuesType') -> None:
|
||||||
return super().initialize(values)
|
return super().initialize(values)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def initClassGui(cls) -> None:
|
||||||
|
# Populate the networks list
|
||||||
|
cls.networks.setValues([
|
||||||
|
gui.choiceItem(v.uuid, v.name)
|
||||||
|
for v in models.Network.objects.all().order_by('name')
|
||||||
|
])
|
||||||
|
|
||||||
def composeSmsUrl(self, userId: str, userName: str, code: str, phone: str) -> str:
|
def composeSmsUrl(self, userId: str, userName: str, code: str, phone: str) -> str:
|
||||||
url = self.smsSendingUrl.value
|
url = self.sendingUrl.value
|
||||||
url = url.replace('{code}', code)
|
url = url.replace('{code}', code)
|
||||||
url = url.replace('{phone}', phone.replace('+', ''))
|
url = url.replace('{phone}', phone.replace('+', ''))
|
||||||
url = url.replace('{+phone}', phone)
|
url = url.replace('{+phone}', phone)
|
||||||
@ -172,99 +195,107 @@ class SMSMFA(mfas.MFA):
|
|||||||
def getSession(self) -> requests.Session:
|
def getSession(self) -> requests.Session:
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
# 0 means no authentication
|
# 0 means no authentication
|
||||||
if self.smsAuthenticationMethod.value == '1':
|
if self.authenticationMethod.value == '1':
|
||||||
session.auth = requests.auth.HTTPBasicAuth(
|
session.auth = requests.auth.HTTPBasicAuth(
|
||||||
username=self.smsAuthenticationUserOrToken.value,
|
username=self.authenticationUserOrToken.value,
|
||||||
password=self.smsAuthenticationPassword.value,
|
password=self.authenticationPassword.value,
|
||||||
)
|
)
|
||||||
elif self.smsAuthenticationMethod.value == '2':
|
elif self.authenticationMethod.value == '2':
|
||||||
session.auth = requests.auth.HTTPDigestAuth(
|
session.auth = requests.auth.HTTPDigestAuth(
|
||||||
self.smsAuthenticationUserOrToken.value,
|
self.authenticationUserOrToken.value,
|
||||||
self.smsAuthenticationPassword.value,
|
self.authenticationPassword.value,
|
||||||
)
|
)
|
||||||
# Any other value means no authentication
|
# Any other value means no authentication
|
||||||
|
|
||||||
# Add headers. Headers are in the form of "Header: Value". (without the quotes)
|
# Add headers. Headers are in the form of "Header: Value". (without the quotes)
|
||||||
if self.smsHeadersParameters.value.strip():
|
if self.headersParameters.value.strip():
|
||||||
for header in self.smsHeadersParameters.value.split('\n'):
|
for header in self.headersParameters.value.split('\n'):
|
||||||
if header.strip():
|
if header.strip():
|
||||||
headerName, headerValue = header.split(':', 1)
|
headerName, headerValue = header.split(':', 1)
|
||||||
session.headers[headerName.strip()] = headerValue.strip()
|
session.headers[headerName.strip()] = headerValue.strip()
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def processResponse(self, response: requests.Response) -> mfas.MFA.RESULT:
|
def processResponse(self, request: 'ExtendedHttpRequest', response: requests.Response) -> mfas.MFA.RESULT:
|
||||||
logger.debug('Response: %s', response)
|
logger.debug('Response: %s', response)
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
if self.smsResponseErrorAction.value == '1':
|
if self.responseErrorAction.value == '1':
|
||||||
raise Exception(_('SMS sending failed'))
|
raise Exception(_('SMS sending failed'))
|
||||||
elif self.smsResponseOkRegex.value.strip():
|
elif self.responseOkRegex.value.strip():
|
||||||
logger.debug('Checking response OK regex: %s: (%s)', self.smsResponseOkRegex.value, re.search(self.smsResponseOkRegex.value, response.text))
|
logger.debug('Checking response OK regex: %s: (%s)', self.responseOkRegex.value, re.search(self.responseOkRegex.value, response.text))
|
||||||
if not re.search(self.smsResponseOkRegex.value, response.text or ''):
|
if not re.search(self.responseOkRegex.value, response.text or ''):
|
||||||
logger.error(
|
logger.error(
|
||||||
'SMS response error: %s',
|
'SMS response error: %s',
|
||||||
response.text,
|
response.text,
|
||||||
)
|
)
|
||||||
if self.smsResponseErrorAction.value == '1':
|
if self.responseErrorAction.value == '0':
|
||||||
|
return mfas.MFA.RESULT.ALLOWED
|
||||||
|
elif self.responseErrorAction.value == '1':
|
||||||
raise Exception('SMS response error')
|
raise Exception('SMS response error')
|
||||||
else:
|
else:
|
||||||
|
isInNetwork = any(i.ipInNetwork(request.ip) for i in models.Network.objects.filter(uuid__in = self.networks.value))
|
||||||
|
if self.responseErrorAction.value == '2':
|
||||||
|
# Allow user to log in if it IP is in the networks list
|
||||||
|
if isInNetwork:
|
||||||
|
return mfas.MFA.RESULT.ALLOWED
|
||||||
|
elif self.responseErrorAction.value == '3':
|
||||||
|
if isInNetwork:
|
||||||
|
raise Exception('SMS response error')
|
||||||
return mfas.MFA.RESULT.ALLOWED
|
return mfas.MFA.RESULT.ALLOWED
|
||||||
return mfas.MFA.RESULT.OK
|
return mfas.MFA.RESULT.OK
|
||||||
|
|
||||||
def sendSMS_GET(self, userId: str, username: str, url: str) -> mfas.MFA.RESULT:
|
|
||||||
return self.processResponse(self.getSession().get(url))
|
|
||||||
|
|
||||||
def getData(
|
def getData(
|
||||||
self, userId: str, username: str, url: str, code: str, phone: str
|
self, request: 'ExtendedHttpRequest', userId: str, username: str, url: str, code: str, phone: str
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
data = ''
|
data = ''
|
||||||
if self.smsSendingParameters.value:
|
if self.sendingParameters.value:
|
||||||
data = (
|
data = (
|
||||||
self.smsSendingParameters.value.replace('{code}', code)
|
self.sendingParameters.value.replace('{code}', code)
|
||||||
.replace('{phone}', phone.replace('+', ''))
|
.replace('{phone}', phone.replace('+', ''))
|
||||||
.replace('{+phone}', phone)
|
.replace('{+phone}', phone)
|
||||||
.replace('{username}', username)
|
.replace('{username}', username)
|
||||||
.replace('{justUsername}', username.split('@')[0])
|
.replace('{justUsername}', username.split('@')[0])
|
||||||
)
|
)
|
||||||
return data.encode(self.smsEncoding.value)
|
return data.encode(self.encoding.value)
|
||||||
|
|
||||||
|
def sendSMS_GET(self, request: 'ExtendedHttpRequest', userId: str, username: str, url: str) -> mfas.MFA.RESULT:
|
||||||
|
return self.processResponse(request, self.getSession().get(url))
|
||||||
|
|
||||||
def sendSMS_POST(
|
def sendSMS_POST(
|
||||||
self, userId: str, username: str, url: str, code: str, phone: str
|
self, request: 'ExtendedHttpRequest', userId: str, username: str, url: str, code: str, phone: str
|
||||||
) -> mfas.MFA.RESULT:
|
) -> mfas.MFA.RESULT:
|
||||||
# Compose POST data
|
# Compose POST data
|
||||||
session = self.getSession()
|
session = self.getSession()
|
||||||
bdata = self.getData(userId, username, url, code, phone)
|
bdata = self.getData(request, userId, username, url, code, phone)
|
||||||
# Add content-length header
|
# Add content-length header
|
||||||
session.headers['Content-Length'] = str(len(bdata))
|
session.headers['Content-Length'] = str(len(bdata))
|
||||||
|
|
||||||
return self.processResponse(session.post(url, data=bdata))
|
return self.processResponse(request, session.post(url, data=bdata))
|
||||||
|
|
||||||
def sendSMS_PUT(
|
def sendSMS_PUT(
|
||||||
self, userId: str, username: str, url: str, code: str, phone: str
|
self, request: 'ExtendedHttpRequest', userId: str, username: str, url: str, code: str, phone: str
|
||||||
) -> mfas.MFA.RESULT:
|
) -> mfas.MFA.RESULT:
|
||||||
# Compose POST data
|
# Compose POST data
|
||||||
data = ''
|
data = ''
|
||||||
bdata = self.getData(userId, username, url, code, phone)
|
bdata = self.getData(request, userId, username, url, code, phone)
|
||||||
return self.processResponse(self.getSession().put(url, data=bdata))
|
return self.processResponse(request, self.getSession().put(url, data=bdata))
|
||||||
|
|
||||||
def sendSMS(
|
def sendSMS(
|
||||||
self, userId: str, username: str, code: str, phone: str
|
self, request: 'ExtendedHttpRequest', userId: str, username: str, code: str, phone: str
|
||||||
) -> mfas.MFA.RESULT:
|
) -> mfas.MFA.RESULT:
|
||||||
url = self.composeSmsUrl(userId, username, code, phone)
|
url = self.composeSmsUrl(userId, username, code, phone)
|
||||||
if self.smsSendingMethod.value == 'GET':
|
if self.sendingMethod.value == 'GET':
|
||||||
return self.sendSMS_GET(userId, username, url)
|
return self.sendSMS_GET(request, userId, username, url)
|
||||||
elif self.smsSendingMethod.value == 'POST':
|
elif self.sendingMethod.value == 'POST':
|
||||||
return self.sendSMS_POST(userId, username, url, code, phone)
|
return self.sendSMS_POST(request, userId, username, url, code, phone)
|
||||||
elif self.smsSendingMethod.value == 'PUT':
|
elif self.sendingMethod.value == 'PUT':
|
||||||
return self.sendSMS_PUT(userId, username, url, code, phone)
|
return self.sendSMS_PUT(request, userId, username, url, code, phone)
|
||||||
else:
|
else:
|
||||||
raise Exception('Unknown SMS sending method')
|
raise Exception('Unknown SMS sending method')
|
||||||
|
|
||||||
def label(self) -> str:
|
def label(self) -> str:
|
||||||
return gettext('MFA Code')
|
return gettext('MFA Code')
|
||||||
|
|
||||||
def sendCode(
|
def sendCode(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, code: str) -> mfas.MFA.RESULT:
|
||||||
self, userId: str, username: str, identifier: str, code: str
|
|
||||||
) -> mfas.MFA.RESULT:
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'Sending SMS code "%s" for user %s (userId="%s", identifier="%s")',
|
'Sending SMS code "%s" for user %s (userId="%s", identifier="%s")',
|
||||||
code,
|
code,
|
||||||
@ -272,4 +303,4 @@ class SMSMFA(mfas.MFA):
|
|||||||
userId,
|
userId,
|
||||||
identifier,
|
identifier,
|
||||||
)
|
)
|
||||||
return self.sendSMS(userId, username, code, identifier)
|
return self.sendSMS(request, userId, username, code, identifier)
|
||||||
|
@ -116,6 +116,12 @@ class Network(UUIDModel, TaggingMixin): # type: ignore
|
|||||||
"""
|
"""
|
||||||
return net.longToIp(self.net_end)
|
return net.longToIp(self.net_end)
|
||||||
|
|
||||||
|
def ipInNetwork(self, ip: str) -> bool:
|
||||||
|
"""
|
||||||
|
Returns true if the specified ip is in this network
|
||||||
|
"""
|
||||||
|
return net.ipToLong(ip) >= self.net_start and net.ipToLong(ip) <= self.net_end
|
||||||
|
|
||||||
def update(self, name: str, netRange: str):
|
def update(self, name: str, netRange: str):
|
||||||
"""
|
"""
|
||||||
Updated this network with provided values
|
Updated this network with provided values
|
||||||
|
Loading…
Reference in New Issue
Block a user