perf: 优化用户 access key 的使用和创建 (#11776)

* perf: 优化用户 access key 的使用和创建

* perf: 优化 access key api

---------

Co-authored-by: ibuler <ibuler@qq.com>
This commit is contained in:
fit2bot
2023-10-10 17:52:52 +08:00
committed by GitHub
parent 30b19d31eb
commit 333746e7c4
28 changed files with 417 additions and 330 deletions

View File

@@ -1,20 +1,44 @@
# -*- coding: utf-8 -*-
#
from rest_framework.viewsets import ModelViewSet
from django.utils.translation import gettext as _
from rest_framework import serializers
from rest_framework.response import Response
from common.api import JMSModelViewSet
from common.permissions import UserConfirmation
from rbac.permissions import RBACPermission
from ..const import ConfirmType
from ..serializers import AccessKeySerializer
class AccessKeyViewSet(ModelViewSet):
class AccessKeyViewSet(JMSModelViewSet):
serializer_class = AccessKeySerializer
search_fields = ['^id', '^secret']
search_fields = ['^id']
permission_classes = [RBACPermission]
def get_queryset(self):
return self.request.user.access_keys.all()
def get_permissions(self):
if self.is_swagger_request():
return super().get_permissions()
if self.action == 'create':
self.permission_classes = [
RBACPermission, UserConfirmation.require(ConfirmType.PASSWORD)
]
return super().get_permissions()
def perform_create(self, serializer):
user = self.request.user
user.create_access_key()
if user.access_keys.count() >= 10:
raise serializers.ValidationError(_('Access keys can be created at most 10'))
key = user.create_access_key()
return key
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
key = self.perform_create(serializer)
return Response({'secret': key.secret, 'id': key.id}, status=201)

View File

@@ -13,7 +13,7 @@ from ..serializers import ConfirmSerializer
class ConfirmBindORUNBindOAuth(RetrieveAPIView):
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.RELOGIN),)
def retrieve(self, request, *args, **kwargs):
return Response('ok')
@@ -24,7 +24,7 @@ class ConfirmApi(RetrieveAPIView, CreateAPIView):
serializer_class = ConfirmSerializer
def get_confirm_backend(self, confirm_type):
backend_classes = ConfirmType.get_can_confirm_backend_classes(confirm_type)
backend_classes = ConfirmType.get_prop_backends(confirm_type)
if not backend_classes:
return
for backend_cls in backend_classes:
@@ -34,7 +34,7 @@ class ConfirmApi(RetrieveAPIView, CreateAPIView):
return backend
def retrieve(self, request, *args, **kwargs):
confirm_type = request.query_params.get('confirm_type')
confirm_type = request.query_params.get('confirm_type', 'password')
backend = self.get_confirm_backend(confirm_type)
if backend is None:
msg = _('This action require verify your MFA')
@@ -51,7 +51,7 @@ class ConfirmApi(RetrieveAPIView, CreateAPIView):
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
confirm_type = validated_data.get('confirm_type')
confirm_type = validated_data.get('confirm_type', 'password')
mfa_type = validated_data.get('mfa_type')
secret_key = validated_data.get('secret_key')

View File

@@ -27,7 +27,7 @@ class DingTalkQRUnBindBase(APIView):
class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase):
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.RELOGIN),)
class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase):

View File

@@ -27,7 +27,7 @@ class FeiShuQRUnBindBase(APIView):
class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase):
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.RELOGIN),)
class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase):

View File

@@ -27,7 +27,7 @@ class WeComQRUnBindBase(APIView):
class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase):
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.RELOGIN),)
class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase):

View File

@@ -1,119 +1,33 @@
# -*- coding: utf-8 -*-
#
import time
import uuid
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.utils import timezone
from django.utils.translation import gettext as _
from rest_framework import HTTP_HEADER_ENCODING
from rest_framework import authentication, exceptions
from six import text_type
from common.auth import signature
from common.utils import get_object_or_none, make_signature, http_to_unixtime
from .base import JMSBaseAuthBackend
from common.utils import get_object_or_none
from ..models import AccessKey, PrivateToken
UserModel = get_user_model()
def date_more_than(d, seconds):
return d is None or (timezone.now() - d).seconds > seconds
def get_request_date_header(request):
date = request.META.get('HTTP_DATE', b'')
if isinstance(date, text_type):
# Work around django test client oddness
date = date.encode(HTTP_HEADER_ENCODING)
return date
def after_authenticate_update_date(user, token=None):
if date_more_than(user.date_api_key_last_used, 60):
user.date_api_key_last_used = timezone.now()
user.save(update_fields=['date_api_key_last_used'])
class AccessKeyAuthentication(authentication.BaseAuthentication):
"""App使用Access key进行签名认证, 目前签名算法比较简单,
app注册或者手动建立后,会生成 access_key_id 和 access_key_secret,
然后使用 如下算法生成签名:
Signature = md5(access_key_secret + '\n' + Date)
example: Signature = md5('d32d2b8b-9a10-4b8d-85bb-1a66976f6fdc' + '\n' +
'Thu, 12 Jan 2017 08:19:41 GMT')
请求时设置请求header
header['Authorization'] = 'Sign access_key_id:Signature' 如:
header['Authorization'] =
'Sign d32d2b8b-9a10-4b8d-85bb-1a66976f6fdc:OKOlmdxgYPZ9+SddnUUDbQ=='
验证时根据相同算法进行验证, 取到access_key_id对应的access_key_id, 从request
headers取到Date, 然后进行md5, 判断得到的结果是否相同, 如果是认证通过, 否则 认证
失败
"""
keyword = 'Sign'
def authenticate(self, request):
auth = authentication.get_authorization_header(request).split()
if not auth or auth[0].lower() != self.keyword.lower().encode():
return None
if len(auth) == 1:
msg = _('Invalid signature header. No credentials provided.')
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _('Invalid signature header. Signature '
'string should not contain spaces.')
raise exceptions.AuthenticationFailed(msg)
try:
sign = auth[1].decode().split(':')
if len(sign) != 2:
msg = _('Invalid signature header. '
'Format like AccessKeyId:Signature')
raise exceptions.AuthenticationFailed(msg)
except UnicodeError:
msg = _('Invalid signature header. '
'Signature string should not contain invalid characters.')
raise exceptions.AuthenticationFailed(msg)
access_key_id = sign[0]
try:
uuid.UUID(access_key_id)
except ValueError:
raise exceptions.AuthenticationFailed('Access key id invalid')
request_signature = sign[1]
return self.authenticate_credentials(
request, access_key_id, request_signature
)
@staticmethod
def authenticate_credentials(request, access_key_id, request_signature):
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:
raise exceptions.AuthenticationFailed(_('Invalid signature.'))
access_key_secret = access_key.secret
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.'))
if not access_key.user.is_active:
raise exceptions.AuthenticationFailed(_('User disabled.'))
return access_key.user, None
def authenticate_header(self, request):
return 'Sign access_key_id:Signature'
if token and hasattr(token, 'date_last_used') and date_more_than(token.date_last_used, 60):
token.date_last_used = timezone.now()
token.save(update_fields=['date_last_used'])
class AccessTokenAuthentication(authentication.BaseAuthentication):
keyword = 'Bearer'
# expiration = settings.TOKEN_EXPIRATION or 3600
model = get_user_model()
def authenticate(self, request):
@@ -125,19 +39,20 @@ class AccessTokenAuthentication(authentication.BaseAuthentication):
msg = _('Invalid token header. No credentials provided.')
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _('Invalid token header. Sign string '
'should not contain spaces.')
msg = _('Invalid token header. Sign string should not contain spaces.')
raise exceptions.AuthenticationFailed(msg)
try:
token = auth[1].decode()
except UnicodeError:
msg = _('Invalid token header. Sign string '
'should not contain invalid characters.')
msg = _('Invalid token header. Sign string should not contain invalid characters.')
raise exceptions.AuthenticationFailed(msg)
return self.authenticate_credentials(token)
user, header = self.authenticate_credentials(token)
after_authenticate_update_date(user)
return user, header
def authenticate_credentials(self, token):
@staticmethod
def authenticate_credentials(token):
model = get_user_model()
user_id = cache.get(token)
user = get_object_or_none(model, id=user_id)
@@ -151,15 +66,23 @@ class AccessTokenAuthentication(authentication.BaseAuthentication):
return self.keyword
class PrivateTokenAuthentication(JMSBaseAuthBackend, authentication.TokenAuthentication):
class PrivateTokenAuthentication(authentication.TokenAuthentication):
model = PrivateToken
def authenticate(self, request):
user_token = super().authenticate(request)
if not user_token:
return
user, token = user_token
after_authenticate_update_date(user, token)
return user, token
class SessionAuthentication(authentication.SessionAuthentication):
def authenticate(self, request):
"""
Returns a `User` if the request session currently has a logged in user.
Otherwise returns `None`.
Otherwise, returns `None`.
"""
# Get the session-based user from the underlying HttpRequest object
@@ -195,6 +118,7 @@ class SignatureAuthentication(signature.SignatureAuthentication):
if not key.is_active:
return None, None
user, secret = key.user, str(key.secret)
after_authenticate_update_date(user, key)
return user, secret
except (AccessKey.DoesNotExist, exceptions.ValidationError):
return None, None

View File

@@ -2,7 +2,6 @@ import abc
class BaseConfirm(abc.ABC):
def __init__(self, user, request):
self.user = user
self.request = request
@@ -23,7 +22,7 @@ class BaseConfirm(abc.ABC):
@property
def content(self):
return ''
return []
@abc.abstractmethod
def authenticate(self, secret_key, mfa_type) -> tuple:

View File

@@ -15,3 +15,14 @@ class ConfirmPassword(BaseConfirm):
ok = authenticate(self.request, username=self.user.username, password=secret_key)
msg = '' if ok else _('Authentication failed password incorrect')
return ok, msg
@property
def content(self):
return [
{
'name': 'password',
'display_name': _('Password'),
'disabled': False,
'placeholder': _('Password'),
}
]

View File

@@ -11,7 +11,7 @@ CONFIRM_BACKEND_MAP = {backend.name: backend for backend in CONFIRM_BACKENDS}
class ConfirmType(TextChoices):
ReLogin = ConfirmReLogin.name, ConfirmReLogin.display_name
RELOGIN = ConfirmReLogin.name, ConfirmReLogin.display_name
PASSWORD = ConfirmPassword.name, ConfirmPassword.display_name
MFA = ConfirmMFA.name, ConfirmMFA.display_name
@@ -23,10 +23,11 @@ class ConfirmType(TextChoices):
return types
@classmethod
def get_can_confirm_backend_classes(cls, confirm_type):
def get_prop_backends(cls, confirm_type):
types = cls.get_can_confirm_types(confirm_type)
backend_classes = [
CONFIRM_BACKEND_MAP[tp] for tp in types if tp in CONFIRM_BACKEND_MAP
CONFIRM_BACKEND_MAP[tp]
for tp in types if tp in CONFIRM_BACKEND_MAP
]
return backend_classes

View File

@@ -0,0 +1,57 @@
# Generated by Django 4.1.10 on 2023-10-10 02:47
import uuid
import authentication.models.access_key
from django.db import migrations, models
def migrate_access_key_secret(apps, schema_editor):
access_key_model = apps.get_model('authentication', 'AccessKey')
db_alias = schema_editor.connection.alias
batch_size = 100
count = 0
while True:
access_keys = access_key_model.objects.using(db_alias).all()[count:count + batch_size]
if not access_keys:
break
count += len(access_keys)
access_keys_updated = []
for access_key in access_keys:
s = access_key.secret
if len(s) != 32 or not s.islower():
continue
try:
access_key.secret = '%s-%s-%s-%s-%s' % (s[:8], s[8:12], s[12:16], s[16:20], s[20:])
access_keys_updated.append(access_key)
except (ValueError, IndexError):
pass
access_key_model.objects.bulk_update(access_keys_updated, fields=['secret'])
class Migration(migrations.Migration):
dependencies = [
('authentication', '0022_passkey'),
]
operations = [
migrations.AddField(
model_name='accesskey',
name='date_last_used',
field=models.DateTimeField(blank=True, null=True, verbose_name='Date last used'),
),
migrations.AddField(
model_name='privatetoken',
name='date_last_used',
field=models.DateTimeField(blank=True, null=True, verbose_name='Date last used'),
),
migrations.AlterField(
model_name='accesskey',
name='secret',
field=models.CharField(default=authentication.models.access_key.default_secret, max_length=36, verbose_name='AccessKeySecret'),
),
migrations.RunPython(migrate_access_key_secret),
]

View File

@@ -5,16 +5,20 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
import common.db.models
from common.utils.random import random_string
def default_secret():
return random_string(36)
class AccessKey(models.Model):
id = models.UUIDField(verbose_name='AccessKeyID', primary_key=True,
default=uuid.uuid4, editable=False)
secret = models.UUIDField(verbose_name='AccessKeySecret',
default=uuid.uuid4, editable=False)
id = models.UUIDField(verbose_name='AccessKeyID', primary_key=True, default=uuid.uuid4, editable=False)
secret = models.CharField(verbose_name='AccessKeySecret', default=default_secret, max_length=36)
user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name='User',
on_delete=common.db.models.CASCADE_SIGNAL_SKIP, related_name='access_keys')
is_active = models.BooleanField(default=True, verbose_name=_('Active'))
date_last_used = models.DateTimeField(null=True, blank=True, verbose_name=_('Date last used'))
date_created = models.DateTimeField(auto_now_add=True)
def get_id(self):

View File

@@ -1,9 +1,11 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework.authtoken.models import Token
class PrivateToken(Token):
"""Inherit from auth token, otherwise migration is boring"""
date_last_used = models.DateTimeField(null=True, blank=True, verbose_name=_('Date last used'))
class Meta:
verbose_name = _('Private Token')

View File

@@ -10,7 +10,7 @@ from users.serializers import UserProfileSerializer
from ..models import AccessKey, TempToken
__all__ = [
'AccessKeySerializer', 'BearerTokenSerializer',
'AccessKeySerializer', 'BearerTokenSerializer',
'SSOTokenSerializer', 'TempTokenSerializer',
]
@@ -18,8 +18,8 @@ __all__ = [
class AccessKeySerializer(serializers.ModelSerializer):
class Meta:
model = AccessKey
fields = ['id', 'secret', 'is_active', 'date_created']
read_only_fields = ['id', 'secret', 'date_created']
fields = ['id', 'is_active', 'date_created', 'date_last_used']
read_only_fields = ['id', 'date_created', 'date_last_used']
class BearerTokenSerializer(serializers.Serializer):
@@ -37,7 +37,8 @@ class BearerTokenSerializer(serializers.Serializer):
def get_keyword(obj):
return 'Bearer'
def update_last_login(self, user):
@staticmethod
def update_last_login(user):
user.last_login = timezone.now()
user.save(update_fields=['last_login'])
@@ -96,7 +97,7 @@ class TempTokenSerializer(serializers.ModelSerializer):
username = request.user.username
kwargs = {
'username': username, 'secret': secret,
'date_expired': timezone.now() + timezone.timedelta(seconds=5*60),
'date_expired': timezone.now() + timezone.timedelta(seconds=5 * 60),
}
token = TempToken(**kwargs)
token.save()

View File

View File

@@ -0,0 +1,34 @@
# Python 示例
# pip install requests drf-httpsig
import datetime
import json
import requests
from httpsig.requests_auth import HTTPSignatureAuth
def get_auth(KeyID, SecretID):
signature_headers = ['(request-target)', 'accept', 'date']
auth = HTTPSignatureAuth(key_id=KeyID, secret=SecretID, algorithm='hmac-sha256', headers=signature_headers)
return auth
def get_user_info(jms_url, auth):
url = jms_url + '/api/v1/users/users/?limit=1'
gmt_form = '%a, %d %b %Y %H:%M:%S GMT'
headers = {
'Accept': 'application/json',
'X-JMS-ORG': '00000000-0000-0000-0000-000000000002',
'Date': datetime.datetime.utcnow().strftime(gmt_form)
}
response = requests.get(url, auth=auth, headers=headers)
print(json.loads(response.text))
if __name__ == '__main__':
jms_url = 'http://localhost:8080'
KeyID = '0753098d-810c-45fb-b42c-b27077147933'
SecretID = 'a58d2530-d7ee-4390-a204-3492e44dde84'
auth = get_auth(KeyID, SecretID)
get_user_info(jms_url, auth)

View File

@@ -99,7 +99,7 @@ class DingTalkOAuthMixin(DingTalkBaseMixin, View):
class DingTalkQRBindView(DingTalkQRMixin, View):
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.ReLogin))
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.RELOGIN))
def get(self, request: HttpRequest):
user = request.user

View File

@@ -69,7 +69,7 @@ class FeiShuQRMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashMe
class FeiShuQRBindView(FeiShuQRMixin, View):
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.ReLogin))
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.RELOGIN))
def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url')

View File

@@ -100,7 +100,7 @@ class WeComOAuthMixin(WeComBaseMixin, View):
class WeComQRBindView(WeComQRMixin, View):
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.ReLogin))
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.RELOGIN))
def get(self, request: HttpRequest):
user = request.user