mirror of https://github.com/jumpserver/jumpserver
Finish access key auth
parent
c5ab49c515
commit
2707012325
|
@ -8,7 +8,7 @@ from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
from common.mixins import IDInFilterMixin
|
from common.mixins import IDInFilterMixin
|
||||||
from common.utils import get_object_or_none, signer
|
from common.utils import get_object_or_none, signer
|
||||||
from .hands import IsSuperUserOrTerminalUser, IsSuperUser
|
from .hands import IsSuperUserOrAppUser, IsSuperUser
|
||||||
from .models import AssetGroup, Asset, IDC, SystemUser, AdminUser
|
from .models import AssetGroup, Asset, IDC, SystemUser, AdminUser
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ class AssetViewSet(IDInFilterMixin, viewsets.ModelViewSet):
|
||||||
queryset = Asset.objects.all()
|
queryset = Asset.objects.all()
|
||||||
serializer_class = serializers.AssetSerializer
|
serializer_class = serializers.AssetSerializer
|
||||||
filter_fields = ('id', 'ip', 'hostname')
|
filter_fields = ('id', 'ip', 'hostname')
|
||||||
|
permission_classes = (IsSuperUserOrAppUser,)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super(AssetViewSet, self).get_queryset()
|
queryset = super(AssetViewSet, self).get_queryset()
|
||||||
|
@ -90,7 +91,7 @@ class AssetListUpdateApi(IDInFilterMixin, ListBulkCreateUpdateDestroyAPIView):
|
||||||
|
|
||||||
|
|
||||||
class SystemUserAuthApi(APIView):
|
class SystemUserAuthApi(APIView):
|
||||||
permission_classes = (IsSuperUserOrTerminalUser,)
|
permission_classes = (IsSuperUserOrAppUser,)
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
system_user_id = request.query_params.get('system_user_id', -1)
|
system_user_id = request.query_params.get('system_user_id', -1)
|
||||||
|
|
|
@ -12,5 +12,5 @@
|
||||||
|
|
||||||
|
|
||||||
from users.utils import AdminUserRequiredMixin
|
from users.utils import AdminUserRequiredMixin
|
||||||
from users.permissions import IsSuperUserOrTerminalUser, IsSuperUser
|
from users.permissions import IsSuperUserOrAppUser, IsSuperUser
|
||||||
from users.models import User, UserGroup
|
from users.models import User, UserGroup
|
||||||
|
|
|
@ -7,7 +7,7 @@ from rest_framework import generics, viewsets
|
||||||
from rest_framework.views import APIView, Response
|
from rest_framework.views import APIView, Response
|
||||||
|
|
||||||
from . import models, serializers
|
from . import models, serializers
|
||||||
from .hands import IsSuperUserOrTerminalUser, Terminal
|
from .hands import IsSuperUserOrAppUser, Terminal
|
||||||
|
|
||||||
|
|
||||||
class ProxyLogViewSet(viewsets.ModelViewSet):
|
class ProxyLogViewSet(viewsets.ModelViewSet):
|
||||||
|
@ -32,13 +32,13 @@ class ProxyLogViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
queryset = models.ProxyLog.objects.all()
|
queryset = models.ProxyLog.objects.all()
|
||||||
serializer_class = serializers.ProxyLogSerializer
|
serializer_class = serializers.ProxyLogSerializer
|
||||||
permission_classes = (IsSuperUserOrTerminalUser,)
|
permission_classes = (IsSuperUserOrAppUser,)
|
||||||
|
|
||||||
|
|
||||||
class CommandLogViewSet(viewsets.ModelViewSet):
|
class CommandLogViewSet(viewsets.ModelViewSet):
|
||||||
queryset = models.CommandLog.objects.all()
|
queryset = models.CommandLog.objects.all()
|
||||||
serializer_class = serializers.CommandLogSerializer
|
serializer_class = serializers.CommandLogSerializer
|
||||||
permission_classes = (IsSuperUserOrTerminalUser,)
|
permission_classes = (IsSuperUserOrAppUser,)
|
||||||
|
|
||||||
|
|
||||||
# class CommandLogTitleApi(APIView):
|
# class CommandLogTitleApi(APIView):
|
||||||
|
|
|
@ -4,5 +4,5 @@
|
||||||
from users.utils import AdminUserRequiredMixin
|
from users.utils import AdminUserRequiredMixin
|
||||||
from users.models import User
|
from users.models import User
|
||||||
from assets.models import Asset, SystemUser
|
from assets.models import Asset, SystemUser
|
||||||
from users.permissions import IsSuperUserOrTerminalUser
|
from users.permissions import IsSuperUserOrAppUser
|
||||||
from terminal.models import Terminal
|
from terminal.models import Terminal
|
||||||
|
|
|
@ -2,6 +2,81 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
兼容Python版本
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
is_py2 = (sys.version_info[0] == 2)
|
||||||
|
is_py3 = (sys.version_info[0] == 3)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import simplejson as json
|
||||||
|
except (ImportError, SyntaxError):
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
if is_py2:
|
||||||
|
|
||||||
|
def to_bytes(data):
|
||||||
|
"""若输入为unicode, 则转为utf-8编码的bytes;其他则原样返回。"""
|
||||||
|
if isinstance(data, unicode):
|
||||||
|
return data.encode('utf-8')
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
|
||||||
|
def to_string(data):
|
||||||
|
"""把输入转换为str对象"""
|
||||||
|
return to_bytes(data)
|
||||||
|
|
||||||
|
def to_unicode(data):
|
||||||
|
"""把输入转换为unicode,要求输入是unicode或者utf-8编码的bytes。"""
|
||||||
|
if isinstance(data, bytes):
|
||||||
|
return data.decode('utf-8')
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
|
||||||
|
def stringify(input):
|
||||||
|
if isinstance(input, dict):
|
||||||
|
return dict([(stringify(key), stringify(value)) for key,value in input.iteritems()])
|
||||||
|
elif isinstance(input, list):
|
||||||
|
return [stringify(element) for element in input]
|
||||||
|
elif isinstance(input, unicode):
|
||||||
|
return input.encode('utf-8')
|
||||||
|
else:
|
||||||
|
return input
|
||||||
|
|
||||||
|
builtin_str = str
|
||||||
|
bytes = str
|
||||||
|
str = unicode
|
||||||
|
|
||||||
|
|
||||||
|
elif is_py3:
|
||||||
|
|
||||||
|
def to_bytes(data):
|
||||||
|
"""若输入为str(即unicode),则转为utf-8编码的bytes;其他则原样返回"""
|
||||||
|
if isinstance(data, str):
|
||||||
|
return data.encode(encoding='utf-8')
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
|
||||||
|
def to_string(data):
|
||||||
|
"""若输入为bytes,则认为是utf-8编码,并返回str"""
|
||||||
|
if isinstance(data, bytes):
|
||||||
|
return data.decode('utf-8')
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
|
||||||
|
def to_unicode(data):
|
||||||
|
"""把输入转换为unicode,要求输入是unicode或者utf-8编码的bytes。"""
|
||||||
|
return to_string(data)
|
||||||
|
|
||||||
|
def stringify(input):
|
||||||
|
return input
|
||||||
|
|
||||||
|
builtin_str = str
|
||||||
|
bytes = bytes
|
||||||
|
str = str
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
pass
|
|
||||||
|
|
|
@ -3,12 +3,17 @@
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
from six import string_types
|
from six import string_types
|
||||||
|
import base64
|
||||||
import os
|
import os
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
import string
|
import string
|
||||||
import logging
|
import logging
|
||||||
import datetime
|
import datetime
|
||||||
import paramiko
|
import time
|
||||||
|
import hashlib
|
||||||
|
from email.utils import formatdate
|
||||||
|
import calendar
|
||||||
|
import threading
|
||||||
|
|
||||||
import paramiko
|
import paramiko
|
||||||
import sshpubkeys
|
import sshpubkeys
|
||||||
|
@ -16,7 +21,6 @@ from itsdangerous import TimedJSONWebSignatureSerializer, JSONWebSignatureSerial
|
||||||
BadSignature, SignatureExpired
|
BadSignature, SignatureExpired
|
||||||
from django.shortcuts import reverse as dj_reverse
|
from django.shortcuts import reverse as dj_reverse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -24,6 +28,8 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import StringIO
|
import StringIO
|
||||||
|
|
||||||
|
from .compat import to_bytes, to_string
|
||||||
|
|
||||||
SECRET_KEY = settings.SECRET_KEY
|
SECRET_KEY = settings.SECRET_KEY
|
||||||
|
|
||||||
|
|
||||||
|
@ -255,4 +261,63 @@ def setattr_bulk(seq, key, value):
|
||||||
return map(set_attr, seq)
|
return map(set_attr, seq)
|
||||||
|
|
||||||
|
|
||||||
|
def content_md5(data):
|
||||||
|
"""计算data的MD5值,经过Base64编码并返回str类型。
|
||||||
|
|
||||||
|
返回值可以直接作为HTTP Content-Type头部的值
|
||||||
|
"""
|
||||||
|
m = hashlib.md5(to_bytes(data))
|
||||||
|
return to_string(base64.b64encode(m.digest()))
|
||||||
|
|
||||||
|
_STRPTIME_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
_GMT_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
|
||||||
|
_ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S.000Z"
|
||||||
|
|
||||||
|
|
||||||
|
def to_unixtime(time_string, format_string):
|
||||||
|
with _STRPTIME_LOCK:
|
||||||
|
return int(calendar.timegm(time.strptime(time_string, format_string)))
|
||||||
|
|
||||||
|
|
||||||
|
def http_date(timeval=None):
|
||||||
|
"""返回符合HTTP标准的GMT时间字符串,用strftime的格式表示就是"%a, %d %b %Y %H:%M:%S GMT"。
|
||||||
|
但不能使用strftime,因为strftime的结果是和locale相关的。
|
||||||
|
"""
|
||||||
|
return formatdate(timeval, usegmt=True)
|
||||||
|
|
||||||
|
|
||||||
|
def http_to_unixtime(time_string):
|
||||||
|
"""把HTTP Date格式的字符串转换为UNIX时间(自1970年1月1日UTC零点的秒数)。
|
||||||
|
|
||||||
|
HTTP Date形如 `Sat, 05 Dec 2015 11:10:29 GMT` 。
|
||||||
|
"""
|
||||||
|
return to_unixtime(time_string, _GMT_FORMAT)
|
||||||
|
|
||||||
|
|
||||||
|
def iso8601_to_unixtime(time_string):
|
||||||
|
"""把ISO8601时间字符串(形如,2012-02-24T06:07:48.000Z)转换为UNIX时间,精确到秒。"""
|
||||||
|
return to_unixtime(time_string, _ISO8601_FORMAT)
|
||||||
|
|
||||||
|
|
||||||
|
def http_to_unixtime(time_string):
|
||||||
|
"""把HTTP Date格式的字符串转换为UNIX时间(自1970年1月1日UTC零点的秒数)。
|
||||||
|
|
||||||
|
HTTP Date形如 `Sat, 05 Dec 2015 11:10:29 GMT` 。
|
||||||
|
"""
|
||||||
|
return to_unixtime(time_string, "%a, %d %b %Y %H:%M:%S GMT")
|
||||||
|
|
||||||
|
|
||||||
|
def make_signature(access_key_secret, date=None):
|
||||||
|
if isinstance(date, int):
|
||||||
|
date_gmt = http_date(date)
|
||||||
|
elif date is None:
|
||||||
|
date_gmt = http_date(int(time.time()))
|
||||||
|
else:
|
||||||
|
date_gmt = date
|
||||||
|
|
||||||
|
data = str(access_key_secret) + "\n" + date_gmt
|
||||||
|
return content_md5(data)
|
||||||
|
|
||||||
|
|
||||||
signer = Signer()
|
signer = Signer()
|
|
@ -272,13 +272,12 @@ REST_FRAMEWORK = {
|
||||||
# Use Django's standard `django.contrib.auth` permissions,
|
# Use Django's standard `django.contrib.auth` permissions,
|
||||||
# or allow read-only access for unauthenticated users.
|
# or allow read-only access for unauthenticated users.
|
||||||
'DEFAULT_PERMISSION_CLASSES': (
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
'users.permissions.IsValidUser',
|
'users.permissions.IsSuperUser',
|
||||||
),
|
),
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
'users.authentication.TerminalAuthentication',
|
'users.authentication.AccessKeyAuthentication',
|
||||||
'users.authentication.AccessTokenAuthentication',
|
'users.authentication.AccessTokenAuthentication',
|
||||||
'rest_framework.authentication.TokenAuthentication',
|
'rest_framework.authentication.TokenAuthentication',
|
||||||
'rest_framework.authentication.BasicAuthentication',
|
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
'rest_framework.authentication.SessionAuthentication',
|
||||||
),
|
),
|
||||||
'DEFAULT_FILTER_BACKENDS': ('django_filters.rest_framework.DjangoFilterBackend',),
|
'DEFAULT_FILTER_BACKENDS': ('django_filters.rest_framework.DjangoFilterBackend',),
|
||||||
|
|
|
@ -11,7 +11,7 @@ from rest_framework.permissions import AllowAny
|
||||||
from common.utils import signer, get_object_or_none
|
from common.utils import signer, get_object_or_none
|
||||||
from .models import Terminal, TerminalHeatbeat
|
from .models import Terminal, TerminalHeatbeat
|
||||||
from .serializers import TerminalSerializer, TerminalHeatbeatSerializer
|
from .serializers import TerminalSerializer, TerminalHeatbeatSerializer
|
||||||
from .hands import IsSuperUserOrTerminalUser, User
|
from .hands import IsSuperUserOrAppUser, User
|
||||||
|
|
||||||
|
|
||||||
class TerminalRegister(ListCreateAPIView):
|
class TerminalRegister(ListCreateAPIView):
|
||||||
|
@ -62,13 +62,13 @@ class TerminalViewSet(viewsets.ModelViewSet):
|
||||||
class TerminalHeatbeatApi(ListCreateAPIView):
|
class TerminalHeatbeatApi(ListCreateAPIView):
|
||||||
queryset = TerminalHeatbeat.objects.all()
|
queryset = TerminalHeatbeat.objects.all()
|
||||||
serializer_class = TerminalHeatbeatSerializer
|
serializer_class = TerminalHeatbeatSerializer
|
||||||
permission_classes = (IsSuperUserOrTerminalUser,)
|
permission_classes = (IsSuperUserOrAppUser,)
|
||||||
|
|
||||||
|
|
||||||
class TerminalHeatbeatViewSet(viewsets.ModelViewSet):
|
class TerminalHeatbeatViewSet(viewsets.ModelViewSet):
|
||||||
queryset = TerminalHeatbeat.objects.all()
|
queryset = TerminalHeatbeat.objects.all()
|
||||||
serializer_class = TerminalHeatbeatSerializer
|
serializer_class = TerminalHeatbeatSerializer
|
||||||
permission_classes = (IsSuperUserOrTerminalUser,)
|
permission_classes = (IsSuperUserOrAppUser,)
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
terminal = request.user
|
terminal = request.user
|
||||||
|
|
|
@ -2,5 +2,5 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
from users.models import User
|
from users.models import User
|
||||||
from users.permissions import IsSuperUserOrTerminalUser
|
from users.permissions import IsSuperUserOrAppUser
|
||||||
from audits.models import ProxyLog
|
from audits.models import ProxyLog
|
|
@ -16,7 +16,7 @@ from common.utils import get_logger
|
||||||
from .utils import check_user_valid, get_or_refresh_token
|
from .utils import check_user_valid, get_or_refresh_token
|
||||||
from .models import User, UserGroup
|
from .models import User, UserGroup
|
||||||
from .hands import write_login_log_async
|
from .hands import write_login_log_async
|
||||||
from .permissions import IsSuperUser, IsTerminalUser, IsValidUser, IsSuperUserOrTerminalUser
|
from .permissions import IsSuperUser, IsAppUser, IsValidUser, IsSuperUserOrAppUser
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -12,7 +14,7 @@ from django.utils.six import text_type
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from rest_framework import HTTP_HEADER_ENCODING
|
from rest_framework import HTTP_HEADER_ENCODING
|
||||||
|
|
||||||
from common.utils import get_object_or_none
|
from common.utils import get_object_or_none, make_signature, http_to_unixtime
|
||||||
from .utils import get_or_refresh_token
|
from .utils import get_or_refresh_token
|
||||||
from .models import User, AccessKey
|
from .models import User, AccessKey
|
||||||
|
|
||||||
|
@ -22,7 +24,6 @@ def get_request_date_header(request):
|
||||||
if isinstance(date, text_type):
|
if isinstance(date, text_type):
|
||||||
# Work around django test client oddness
|
# Work around django test client oddness
|
||||||
date = date.encode(HTTP_HEADER_ENCODING)
|
date = date.encode(HTTP_HEADER_ENCODING)
|
||||||
|
|
||||||
return date
|
return date
|
||||||
|
|
||||||
|
|
||||||
|
@ -54,18 +55,30 @@ class AccessKeyAuthentication(authentication.BaseAuthentication):
|
||||||
raise exceptions.AuthenticationFailed(msg)
|
raise exceptions.AuthenticationFailed(msg)
|
||||||
|
|
||||||
access_key_id = sign[0]
|
access_key_id = sign[0]
|
||||||
secret = sign[1]
|
request_signature = sign[1]
|
||||||
date =
|
|
||||||
|
|
||||||
return self.authenticate_credentials(sign)
|
return self.authenticate_credentials(request, access_key_id, request_signature)
|
||||||
|
|
||||||
def authenticate_credentials(self, access_key_id, secret, datetime):
|
|
||||||
access_key_id = sign[0]
|
|
||||||
secret = sign[1]
|
|
||||||
|
|
||||||
|
def authenticate_credentials(self, request, access_key_id, request_signature):
|
||||||
access_key = get_object_or_none(AccessKey, id=access_key_id)
|
access_key = get_object_or_none(AccessKey, id=access_key_id)
|
||||||
|
request_date = get_request_date_header(request)
|
||||||
if access_key is None or not access_key.user:
|
if access_key is None or not access_key.user:
|
||||||
raise exceptions.AuthenticationFailed(_('Invalid signature.'))
|
raise exceptions.AuthenticationFailed(_('Invalid signature.'))
|
||||||
|
access_key_secret = access_key.secret
|
||||||
|
|
||||||
|
print(request_date)
|
||||||
|
|
||||||
|
try:
|
||||||
|
request_unix_time = http_to_unixtime(request_date)
|
||||||
|
except ValueError:
|
||||||
|
raise exceptions.AuthenticationFailed(_('HTTP header: Date not provide or not %a, %d %b %Y %H:%M:%S GMT'))
|
||||||
|
|
||||||
|
if int(time.time()) - request_unix_time > 15*60:
|
||||||
|
raise exceptions.AuthenticationFailed(_('Expired, more than 15 minutes'))
|
||||||
|
|
||||||
|
signature = make_signature(access_key_secret, request_date)
|
||||||
|
if not signature == request_signature:
|
||||||
|
raise exceptions.AuthenticationFailed(_('Invalid signature. %s: %s' % (signature, request_signature)))
|
||||||
|
|
||||||
if not access_key.user.is_active:
|
if not access_key.user.is_active:
|
||||||
raise exceptions.AuthenticationFailed(_('User disabled.'))
|
raise exceptions.AuthenticationFailed(_('User disabled.'))
|
||||||
|
|
|
@ -101,8 +101,8 @@ class User(AbstractUser):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_terminal(self):
|
def is_app(self):
|
||||||
return False
|
return self.role == 'App'
|
||||||
|
|
||||||
@is_superuser.setter
|
@is_superuser.setter
|
||||||
def is_superuser(self, value):
|
def is_superuser(self, value):
|
||||||
|
|
|
@ -23,12 +23,12 @@ class IsValidUser(permissions.IsAuthenticated, permissions.BasePermission):
|
||||||
and request.user.is_valid
|
and request.user.is_valid
|
||||||
|
|
||||||
|
|
||||||
class IsTerminalUser(IsValidUser, permissions.BasePermission):
|
class IsAppUser(IsValidUser, permissions.BasePermission):
|
||||||
"""Allows access only to app user """
|
"""Allows access only to app user """
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
return super(IsTerminalUser, self).has_permission(request, view) \
|
return super(IsAppUser, self).has_permission(request, view) \
|
||||||
and isinstance(request.user, Terminal)
|
and request.user.is_app()
|
||||||
|
|
||||||
|
|
||||||
class IsSuperUser(IsValidUser, permissions.BasePermission):
|
class IsSuperUser(IsValidUser, permissions.BasePermission):
|
||||||
|
@ -39,12 +39,12 @@ class IsSuperUser(IsValidUser, permissions.BasePermission):
|
||||||
and request.user.is_superuser
|
and request.user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
class IsSuperUserOrTerminalUser(IsValidUser, permissions.BasePermission):
|
class IsSuperUserOrAppUser(IsValidUser, permissions.BasePermission):
|
||||||
"""Allows access between superuser and app user"""
|
"""Allows access between superuser and app user"""
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
return super(IsSuperUserOrTerminalUser, self).has_permission(request, view) \
|
return super(IsSuperUserOrAppUser, self).has_permission(request, view) \
|
||||||
and (request.user.is_superuser or request.user.is_terminal)
|
and (request.user.is_superuser or request.user.is_app)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
Loading…
Reference in New Issue