mirror of
https://github.com/encode/django-rest-framework.git
synced 2024-11-22 17:47:04 +03:00
Merge & clean OAuth support
This commit is contained in:
commit
d4e3610e71
|
@ -14,6 +14,8 @@ env:
|
|||
install:
|
||||
- pip install $DJANGO
|
||||
- pip install defusedxml==0.3
|
||||
- "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2==1.5.211 --use-mirrors; fi"
|
||||
- "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.0 --use-mirrors; fi"
|
||||
- "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi"
|
||||
- "if [[ ${TRAVIS_PYTHON_VERSION::1} == '3' ]]; then pip install https://github.com/alex/django-filter/tarball/master; fi"
|
||||
- export PYTHONPATH=.
|
||||
|
|
|
@ -207,6 +207,20 @@ Unauthenticated responses that are denied permission will result in an `HTTP 403
|
|||
|
||||
If you're using an AJAX style API with SessionAuthentication, you'll need to make sure you include a valid CSRF token for any "unsafe" HTTP method calls, such as `PUT`, `PATCH`, `POST` or `DELETE` requests. See the [Django CSRF documentation][csrf-ajax] for more details.
|
||||
|
||||
## OAuthAuthentication
|
||||
|
||||
This authentication uses [OAuth 1.0][rfc5849] authentication scheme. It depends on optional `django-oauth-plus` and `oauth2` packages. In order to make it work you must istall these packages and add `oauth_provider` (from `django-oauth-plus`) to your `INSTALLED_APPS`:
|
||||
|
||||
INSTALLED_APPS = (
|
||||
#(...)
|
||||
`oauth_provider`,
|
||||
)
|
||||
|
||||
OAuthAuthentication class provides only token verification and signature validation for requests. It doesn't provide authorization flow for your clients. You still need to implement your own views for accessing and authorizing Reqest/Access Tokens. This is because there are many different OAuth flows in use. Almost always they require end-user interaction, and most likely this is what you want to design yourself.
|
||||
|
||||
Luckily `django-oauth-plus` provides simple foundation for classic 'three-legged' oauth flow, so if it is what you need please refer to [its documentation](http://code.larlet.fr/django-oauth-plus/wiki/Home). This documentation will provide you also information about how to work with supplied models and change basic settings.
|
||||
|
||||
|
||||
# Custom authentication
|
||||
|
||||
To implement a custom authentication scheme, subclass `BaseAuthentication` and override the `.authenticate(self, request)` method. The method should return a two-tuple of `(user, auth)` if authentication succeeds, or `None` otherwise.
|
||||
|
@ -262,3 +276,4 @@ HTTP digest authentication is a widely implemented scheme that was intended to r
|
|||
[south-dependencies]: http://south.readthedocs.org/en/latest/dependencies.html
|
||||
[juanriaza]: https://github.com/juanriaza
|
||||
[djangorestframework-digestauth]: https://github.com/juanriaza/django-rest-framework-digestauth
|
||||
[rfc5849] : http://tools.ietf.org/html/rfc5849
|
||||
|
|
|
@ -36,6 +36,9 @@ The following packages are optional:
|
|||
* [PyYAML][yaml] (3.10+) - YAML content-type support.
|
||||
* [defusedxml][defusedxml] (0.3+) - XML content-type support.
|
||||
* [django-filter][django-filter] (0.5.4+) - Filtering support.
|
||||
* [django-oauth-plus][django-oauth-plus] (2.0+) and [oauth2][oauth2] (1.5.211+) - OAuth 1.0a support.
|
||||
|
||||
**Note**: The `oauth2` python package is badly misnamed, and actually provides oauth1.0a support.
|
||||
|
||||
## Installation
|
||||
|
||||
|
@ -180,6 +183,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|||
[yaml]: http://pypi.python.org/pypi/PyYAML
|
||||
[defusedxml]: https://pypi.python.org/pypi/defusedxml
|
||||
[django-filter]: http://pypi.python.org/pypi/django-filter
|
||||
[oauth2]: https://github.com/simplegeo/python-oauth2
|
||||
[django-oauth-plus]: https://bitbucket.org/david/django-oauth-plus/wiki/Home
|
||||
[0.4]: https://github.com/tomchristie/django-rest-framework/tree/0.4.X
|
||||
[image]: img/quickstart.png
|
||||
[sandbox]: http://restframework.herokuapp.com/
|
||||
|
|
|
@ -2,3 +2,5 @@ markdown>=2.1.0
|
|||
PyYAML>=3.10
|
||||
defusedxml>=0.3
|
||||
django-filter>=0.5.4
|
||||
django-oauth-plus>=2.0
|
||||
oauth2>=1.5.211
|
||||
|
|
|
@ -3,9 +3,10 @@ Provides a set of pluggable authentication policies.
|
|||
"""
|
||||
from __future__ import unicode_literals
|
||||
from django.contrib.auth import authenticate
|
||||
from django.utils.encoding import DjangoUnicodeDecodeError
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from rest_framework import exceptions, HTTP_HEADER_ENCODING
|
||||
from rest_framework.compat import CsrfViewMiddleware
|
||||
from rest_framework.compat import oauth, oauth_provider, oauth_provider_store
|
||||
from rest_framework.authtoken.models import Token
|
||||
import base64
|
||||
|
||||
|
@ -58,11 +59,7 @@ class BasicAuthentication(BaseAuthentication):
|
|||
except (TypeError, UnicodeDecodeError):
|
||||
raise exceptions.AuthenticationFailed('Invalid basic header')
|
||||
|
||||
try:
|
||||
userid, password = auth_parts[0], auth_parts[2]
|
||||
except DjangoUnicodeDecodeError:
|
||||
raise exceptions.AuthenticationFailed('Invalid basic header')
|
||||
|
||||
userid, password = auth_parts[0], auth_parts[2]
|
||||
return self.authenticate_credentials(userid, password)
|
||||
|
||||
def authenticate_credentials(self, userid, password):
|
||||
|
@ -155,4 +152,95 @@ class TokenAuthentication(BaseAuthentication):
|
|||
return 'Token'
|
||||
|
||||
|
||||
# TODO: OAuthAuthentication
|
||||
class OAuthAuthentication(BaseAuthentication):
|
||||
"""
|
||||
OAuth 1.0a authentication backend using `django-oauth-plus` and `oauth2`.
|
||||
|
||||
Note: The `oauth2` package actually provides oauth1.0a support. Urg.
|
||||
"""
|
||||
www_authenticate_realm = 'api'
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(OAuthAuthentication, self).__init__(**kwargs)
|
||||
|
||||
if oauth is None:
|
||||
raise ImproperlyConfigured("The 'oauth2' package could not be imported. It is required for use with the 'OAuthAuthentication' class.")
|
||||
|
||||
if oauth_provider is None:
|
||||
raise ImproperlyConfigured("The 'django-oauth-plus' package could not be imported. It is required for use with the 'OAuthAuthentication' class.")
|
||||
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Returns two-tuple of (user, token) if authentication succeeds,
|
||||
or None otherwise.
|
||||
"""
|
||||
if not self.is_valid_request(request):
|
||||
return None
|
||||
|
||||
oauth_request = oauth_provider.utils.get_oauth_request(request)
|
||||
|
||||
if not self.check_nonce(request, oauth_request):
|
||||
raise exceptions.AuthenticationFailed("Nonce check failed")
|
||||
|
||||
try:
|
||||
consumer_key = oauth_request.get_parameter('oauth_consumer_key')
|
||||
consumer = oauth_provider_store.get_consumer(request, oauth_request, consumer_key)
|
||||
except oauth_provider_store.InvalidConsumerError, err:
|
||||
raise exceptions.AuthenticationFailed(err)
|
||||
|
||||
if consumer.status != oauth_provider.consts.ACCEPTED:
|
||||
msg = 'Invalid consumer key status: %s' % consumer.get_status_display()
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
try:
|
||||
token_param = oauth_request.get_parameter('oauth_token')
|
||||
token = oauth_provider_store.get_access_token(request, oauth_request, consumer, token_param)
|
||||
except oauth_provider_store.InvalidTokenError:
|
||||
msg = 'Invalid access token: %s' % oauth_request.get_parameter('oauth_token')
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
try:
|
||||
self.validate_token(request, consumer, token)
|
||||
except oauth.Error, e:
|
||||
raise exceptions.AuthenticationFailed(e.message)
|
||||
|
||||
user = token.user
|
||||
|
||||
if not user.is_active:
|
||||
raise exceptions.AuthenticationFailed('User inactive or deleted: %s' % user.username)
|
||||
|
||||
return (token.user, token)
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return 'OAuth realm="%s"' % self.www_authenticate_realm
|
||||
|
||||
def is_in(self, params):
|
||||
"""
|
||||
Checks to ensure that all the OAuth parameter names are in the
|
||||
provided ``params``.
|
||||
"""
|
||||
for param_name in oauth_provider.consts.OAUTH_PARAMETERS_NAMES:
|
||||
if param_name not in params:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def is_valid_request(self, request):
|
||||
"""
|
||||
Checks whether the required parameters are either in the HTTP
|
||||
`Authorization` header sent by some clients.
|
||||
(The preferred method according to OAuth spec.)
|
||||
Or fall back to `GET/POST`.
|
||||
"""
|
||||
auth_params = request.META.get('HTTP_AUTHORIZATION', [])
|
||||
return self.is_in(auth_params) or self.is_in(request.REQUEST)
|
||||
|
||||
def validate_token(self, request, consumer, token):
|
||||
oauth_server, oauth_request = oauth_provider.utils.initialize_server_request(request)
|
||||
return oauth_server.verify_request(oauth_request, consumer, token)
|
||||
|
||||
def check_nonce(self, request, oauth_request):
|
||||
"""
|
||||
Checks nonce of request.
|
||||
"""
|
||||
return oauth_provider.store.store.check_nonce(request, oauth_request, oauth_request['oauth_nonce'])
|
||||
|
|
|
@ -426,3 +426,18 @@ try:
|
|||
import defusedxml.ElementTree as etree
|
||||
except ImportError:
|
||||
etree = None
|
||||
|
||||
# OAuth is optional
|
||||
try:
|
||||
# Note: The `oauth2` package actually provides oauth1.0a support. Urg.
|
||||
import oauth2 as oauth
|
||||
except ImportError:
|
||||
oauth = None
|
||||
|
||||
# OAuth is optional
|
||||
try:
|
||||
import oauth_provider
|
||||
from oauth_provider.store import store as oauth_provider_store
|
||||
except ImportError:
|
||||
oauth_provider = None
|
||||
oauth_provider_store = None
|
||||
|
|
|
@ -97,9 +97,19 @@ INSTALLED_APPS = (
|
|||
# 'django.contrib.admindocs',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'rest_framework.tests'
|
||||
'rest_framework.tests',
|
||||
)
|
||||
|
||||
# OAuth is optional and won't work if there is no oauth_provider & oauth2
|
||||
try:
|
||||
import oauth_provider
|
||||
import oauth2
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
INSTALLED_APPS += ('oauth_provider',)
|
||||
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
|
||||
PASSWORD_HASHERS = (
|
||||
|
|
|
@ -2,22 +2,26 @@ from __future__ import unicode_literals
|
|||
from django.contrib.auth.models import User
|
||||
from django.http import HttpResponse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils import unittest
|
||||
from rest_framework import HTTP_HEADER_ENCODING
|
||||
from rest_framework import exceptions
|
||||
from rest_framework import permissions
|
||||
from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.authentication import (
|
||||
BaseAuthentication,
|
||||
TokenAuthentication,
|
||||
BasicAuthentication,
|
||||
SessionAuthentication
|
||||
SessionAuthentication,
|
||||
OAuthAuthentication
|
||||
)
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.compat import patterns
|
||||
from rest_framework.tests.utils import RequestFactory
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.compat import oauth, oauth_provider
|
||||
import json
|
||||
import base64
|
||||
import time
|
||||
|
||||
|
||||
factory = RequestFactory()
|
||||
|
@ -41,6 +45,7 @@ urlpatterns = patterns('',
|
|||
(r'^basic/$', MockView.as_view(authentication_classes=[BasicAuthentication])),
|
||||
(r'^token/$', MockView.as_view(authentication_classes=[TokenAuthentication])),
|
||||
(r'^auth-token/$', 'rest_framework.authtoken.views.obtain_auth_token'),
|
||||
(r'^oauth/$', MockView.as_view(authentication_classes=[OAuthAuthentication]))
|
||||
)
|
||||
|
||||
|
||||
|
@ -222,3 +227,156 @@ class IncorrectCredentialsTests(TestCase):
|
|||
response = view(request)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(response.data, {'detail': 'Bad credentials'})
|
||||
|
||||
|
||||
class OAuthTests(TestCase):
|
||||
"""OAuth 1.0a authentication"""
|
||||
urls = 'rest_framework.tests.authentication'
|
||||
|
||||
def setUp(self):
|
||||
# these imports are here because oauth is optional and hiding them in try..except block or compat
|
||||
# could obscure problems if something breaks
|
||||
from oauth_provider.models import Consumer, Resource
|
||||
from oauth_provider.models import Token as OAuthToken
|
||||
from oauth_provider import consts
|
||||
|
||||
self.consts = consts
|
||||
|
||||
self.csrf_client = Client(enforce_csrf_checks=True)
|
||||
self.username = 'john'
|
||||
self.email = 'lennon@thebeatles.com'
|
||||
self.password = 'password'
|
||||
self.user = User.objects.create_user(self.username, self.email, self.password)
|
||||
|
||||
self.CONSUMER_KEY = 'consumer_key'
|
||||
self.CONSUMER_SECRET = 'consumer_secret'
|
||||
self.TOKEN_KEY = "token_key"
|
||||
self.TOKEN_SECRET = "token_secret"
|
||||
|
||||
self.consumer = Consumer.objects.create(key=self.CONSUMER_KEY, secret=self.CONSUMER_SECRET,
|
||||
name='example', user=self.user, status=self.consts.ACCEPTED)
|
||||
|
||||
self.resource = Resource.objects.create(name="resource name", url="api/")
|
||||
self.token = OAuthToken.objects.create(user=self.user, consumer=self.consumer, resource=self.resource,
|
||||
token_type=OAuthToken.ACCESS, key=self.TOKEN_KEY, secret=self.TOKEN_SECRET, is_approved=True
|
||||
)
|
||||
|
||||
def _create_authorization_header(self):
|
||||
params = {
|
||||
'oauth_version': "1.0",
|
||||
'oauth_nonce': oauth.generate_nonce(),
|
||||
'oauth_timestamp': int(time.time()),
|
||||
'oauth_token': self.token.key,
|
||||
'oauth_consumer_key': self.consumer.key
|
||||
}
|
||||
|
||||
req = oauth.Request(method="GET", url="http://example.com", parameters=params)
|
||||
|
||||
signature_method = oauth.SignatureMethod_PLAINTEXT()
|
||||
req.sign_request(signature_method, self.consumer, self.token)
|
||||
|
||||
return req.to_header()["Authorization"]
|
||||
|
||||
def _create_authorization_url_parameters(self):
|
||||
params = {
|
||||
'oauth_version': "1.0",
|
||||
'oauth_nonce': oauth.generate_nonce(),
|
||||
'oauth_timestamp': int(time.time()),
|
||||
'oauth_token': self.token.key,
|
||||
'oauth_consumer_key': self.consumer.key
|
||||
}
|
||||
|
||||
req = oauth.Request(method="GET", url="http://example.com", parameters=params)
|
||||
|
||||
signature_method = oauth.SignatureMethod_PLAINTEXT()
|
||||
req.sign_request(signature_method, self.consumer, self.token)
|
||||
return dict(req)
|
||||
|
||||
@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
|
||||
@unittest.skipUnless(oauth, 'oauth2 not installed')
|
||||
def test_post_form_passing_oauth(self):
|
||||
"""Ensure POSTing form over OAuth with correct credentials passes and does not require CSRF"""
|
||||
auth = self._create_authorization_header()
|
||||
response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
|
||||
@unittest.skipUnless(oauth, 'oauth2 not installed')
|
||||
def test_post_form_repeated_nonce_failing_oauth(self):
|
||||
"""Ensure POSTing form over OAuth with repeated auth (same nonces and timestamp) credentials fails"""
|
||||
auth = self._create_authorization_header()
|
||||
response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# simulate reply attack auth header containes already used (nonce, timestamp) pair
|
||||
response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
|
||||
self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN))
|
||||
|
||||
@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
|
||||
@unittest.skipUnless(oauth, 'oauth2 not installed')
|
||||
def test_post_form_token_removed_failing_oauth(self):
|
||||
"""Ensure POSTing when there is no OAuth access token in db fails"""
|
||||
self.token.delete()
|
||||
auth = self._create_authorization_header()
|
||||
response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
|
||||
self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN))
|
||||
|
||||
@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
|
||||
@unittest.skipUnless(oauth, 'oauth2 not installed')
|
||||
def test_post_form_consumer_status_not_accepted_failing_oauth(self):
|
||||
"""Ensure POSTing when consumer status is anything other than ACCEPTED fails"""
|
||||
for consumer_status in (self.consts.CANCELED, self.consts.PENDING, self.consts.REJECTED):
|
||||
self.consumer.status = consumer_status
|
||||
self.consumer.save()
|
||||
|
||||
auth = self._create_authorization_header()
|
||||
response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
|
||||
self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN))
|
||||
|
||||
@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
|
||||
@unittest.skipUnless(oauth, 'oauth2 not installed')
|
||||
def test_post_form_with_request_token_failing_oauth(self):
|
||||
"""Ensure POSTing with unauthorized request token instead of access token fails"""
|
||||
self.token.token_type = self.token.REQUEST
|
||||
self.token.save()
|
||||
|
||||
auth = self._create_authorization_header()
|
||||
response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
|
||||
self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN))
|
||||
|
||||
@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
|
||||
@unittest.skipUnless(oauth, 'oauth2 not installed')
|
||||
def test_post_form_with_urlencoded_parameters(self):
|
||||
"""Ensure POSTing with x-www-form-urlencoded auth parameters passes"""
|
||||
params = self._create_authorization_url_parameters()
|
||||
response = self.csrf_client.post('/oauth/', params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
|
||||
@unittest.skipUnless(oauth, 'oauth2 not installed')
|
||||
def test_get_form_with_url_parameters(self):
|
||||
"""Ensure GETing with auth in url parameters passes"""
|
||||
params = self._create_authorization_url_parameters()
|
||||
response = self.csrf_client.get('/oauth/', params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
|
||||
@unittest.skipUnless(oauth, 'oauth2 not installed')
|
||||
def test_post_hmac_sha1_signature_passes(self):
|
||||
"""Ensure POSTing using HMAC_SHA1 signature method passes"""
|
||||
params = {
|
||||
'oauth_version': "1.0",
|
||||
'oauth_nonce': oauth.generate_nonce(),
|
||||
'oauth_timestamp': int(time.time()),
|
||||
'oauth_token': self.token.key,
|
||||
'oauth_consumer_key': self.consumer.key
|
||||
}
|
||||
|
||||
req = oauth.Request(method="POST", url="http://testserver/oauth/", parameters=params)
|
||||
|
||||
signature_method = oauth.SignatureMethod_HMAC_SHA1()
|
||||
req.sign_request(signature_method, self.consumer, self.token)
|
||||
auth = req.to_header()["Authorization"]
|
||||
|
||||
response = self.csrf_client.post('/oauth/', HTTP_AUTHORIZATION=auth)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
|
12
tox.ini
12
tox.ini
|
@ -21,33 +21,45 @@ deps = django==1.5
|
|||
basepython = python2.7
|
||||
deps = django==1.5
|
||||
django-filter==0.5.4
|
||||
django-oauth-plus==2.0
|
||||
oauth2==1.5.211
|
||||
|
||||
[testenv:py2.6-django1.5]
|
||||
basepython = python2.6
|
||||
deps = django==1.5
|
||||
django-filter==0.5.4
|
||||
defusedxml==0.3
|
||||
django-oauth-plus==2.0
|
||||
oauth2==1.5.211
|
||||
|
||||
[testenv:py2.7-django1.4]
|
||||
basepython = python2.7
|
||||
deps = django==1.4.3
|
||||
django-filter==0.5.4
|
||||
defusedxml==0.3
|
||||
django-oauth-plus==2.0
|
||||
oauth2==1.5.211
|
||||
|
||||
[testenv:py2.6-django1.4]
|
||||
basepython = python2.6
|
||||
deps = django==1.4.3
|
||||
django-filter==0.5.4
|
||||
defusedxml==0.3
|
||||
django-oauth-plus==2.0
|
||||
oauth2==1.5.211
|
||||
|
||||
[testenv:py2.7-django1.3]
|
||||
basepython = python2.7
|
||||
deps = django==1.3.5
|
||||
django-filter==0.5.4
|
||||
defusedxml==0.3
|
||||
django-oauth-plus==2.0
|
||||
oauth2==1.5.211
|
||||
|
||||
[testenv:py2.6-django1.3]
|
||||
basepython = python2.6
|
||||
deps = django==1.3.5
|
||||
django-filter==0.5.4
|
||||
defusedxml==0.3
|
||||
django-oauth-plus==2.0
|
||||
oauth2==1.5.211
|
||||
|
|
Loading…
Reference in New Issue
Block a user