Skip to content

Commit

Permalink
Release 5.11.12
Browse files Browse the repository at this point in the history
### Changelog:
- Feature(backend): Provide authorization via jwt in tests.
- Fix(backend): SuperUser permission was only staff member permission.
- Fix(backend): Pass-through authorization and cookies in bulk on any auth classes.
- Fix(backend): De-authorization after changing password with OID auth.

See merge request vst/vst-utils!677
  • Loading branch information
flwd3m committed Nov 19, 2024
2 parents ae991f9 + 1f0cd1b commit 2820532
Show file tree
Hide file tree
Showing 11 changed files with 116 additions and 36 deletions.
46 changes: 31 additions & 15 deletions doc/locale/ru/LC_MESSAGES/backend.po
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: VST Utils 5.0.4\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-11-05 03:35+0000\n"
"POT-Creation-Date: 2024-11-19 06:27+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <[email protected]>\n"
Expand Down Expand Up @@ -2589,32 +2589,32 @@ msgid ""
" the link points to an existing resource in the interface to avoid 404 "
"errors."
msgstr ""
"**link** *(необязательно)*: URL для другой страницы. Если "
"указан, будет отображаться текст как ссылка. Если не указан, будет "
"отображаться просто текст. Значение должно быть совместимым с "
"`параметром метода push Vue Router <https://router.vuejs.org/api/interfaces/Router.html#push>`_. "
"**link** *(необязательно)*: URL для другой страницы. Если указан, будет "
"отображаться текст как ссылка. Если не указан, будет отображаться просто "
"текст. Значение должно быть совместимым с `параметром метода push Vue "
"Router <https://router.vuejs.org/api/interfaces/Router.html#push>`_. "
"Убедитесь, что ссылка указывает на существующий ресурс в интерфейсе для "
"избежания ошибок 404."

#: of vstutils.api.fields.RouterLinkField:11
#: of vstutils.api.fields.RouterLinkField:14
msgid ""
"**label**: The text to display. This is required whether or not a link is"
" provided."
msgstr "**label**: Текст для отображения. Обязательное поле."

#: of vstutils.api.fields.RouterLinkField:14
#: of vstutils.api.fields.RouterLinkField:17
msgid "For simpler use cases, you might consider using :class:`.FkField`."
msgstr "Для простых случаев использования см. :class:`.FkField`."

#: of vstutils.api.fields.RouterLinkField:16
#: of vstutils.api.fields.RouterLinkField:19
msgid "**Examples:**"
msgstr "**Примеры:**"

#: of vstutils.api.fields.RouterLinkField:18
#: of vstutils.api.fields.RouterLinkField:21
msgid "*Using a model class method:*"
msgstr "*Использование метода класса модели:*"

#: of vstutils.api.fields.RouterLinkField:42
#: of vstutils.api.fields.RouterLinkField:45
msgid ""
"In this example, the ``get_link`` method in the ``Author`` model returns "
"a dictionary containing the ``link`` and ``label``. The "
Expand All @@ -2626,11 +2626,11 @@ msgstr ""
"метод для отображения имени автора как ссылку на страницу с "
"подробностями."

#: of vstutils.api.fields.RouterLinkField:45
#: of vstutils.api.fields.RouterLinkField:50
msgid "*Using a custom field class:*"
msgstr "*Использование пользовательского класса поля:*"

#: of vstutils.api.fields.RouterLinkField:70
#: of vstutils.api.fields.RouterLinkField:75
msgid ""
"In this example, we create a custom field ``AuthorLinkField`` by "
"subclassing ``RouterLinkField``. We override the ``to_representation`` "
Expand All @@ -2645,23 +2645,23 @@ msgstr ""
"используется в вьюсете для отображения имени автора как кликабельной "
"ссылки."

#: of vstutils.api.fields.RouterLinkField:75
#: of vstutils.api.fields.RouterLinkField:81
msgid ""
"The field is read-only and is intended to display dynamic links based on "
"the instance data."
msgstr ""
"Поле является только для чтения и предназначено для отображения "
"динамических ссылок на основе данных экземпляра."

#: of vstutils.api.fields.RouterLinkField:76
#: of vstutils.api.fields.RouterLinkField:82
msgid ""
"If the ``link`` key is omitted or ``None``, the field will display the "
"``label`` as plain text instead of a link."
msgstr ""
"Если ключ ``link`` отсутствует или имеет значение ``None``, поле "
"отображает текст как обычный текст вместо ссылки."

#: of vstutils.api.fields.RouterLinkField:79
#: of vstutils.api.fields.RouterLinkField:86
msgid ""
"Always ensure that the ``link`` provided points to a valid route within "
"your application to prevent users from encountering 404 errors."
Expand Down Expand Up @@ -5252,6 +5252,18 @@ msgstr ""
"Делает транзакционный bulk-запрос и проверяет код состояния (200 по "
"умолчанию)"

#: ../../docstring of vstutils.tests.BaseTestCase.client_token_app_id:1
msgid "oAuth2 client id"
msgstr "ID клиента для тестов в oAuth2"

#: ../../docstring of vstutils.tests.BaseTestCase.client_token_grant_type:1
msgid "oAuth2 grant type"
msgstr "Тип авторизации в oAuth2"

#: ../../docstring of vstutils.tests.BaseTestCase.client_token_scopes:1
msgid "oAuth2 scopes"
msgstr ""

#: of vstutils.tests.BaseTestCase.details_test:1
msgid ""
"Test for get details of model. If you setup additional named arguments, "
Expand Down Expand Up @@ -5493,6 +5505,10 @@ msgstr ""
msgid "Simple function which returns uuid1 string."
msgstr "Простая функция, возвращающая строку uuid1."

#: of vstutils.oauth2.authorization_server.AuthorizationServer:1
msgid "oAuth2 server class"
msgstr "Класс авторизации для oAuth2"

#: ../../docstring of vstutils.tests.BaseTestCase.std_codes:1
msgid ""
"Default http status codes for different http methods. Uses in "
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ exclude_lines = [
]

[tool.bandit]
exclude_dirs = [
'vstutils/tests.py',
]
skips = [
"B403",
"B404",
Expand All @@ -132,7 +135,7 @@ skips = [
]

[tool.mypy]
python_version = 3.8
python_version = "3.10"
#strict = true
allow_redefinition = true
check_untyped_defs = true
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
'vstutils.api.endpoint',
'vstutils.api.validators',
'vstutils.api.actions',
'vstutils.oauth2.authentication',
'vstutils.models.base',
'vstutils.models.queryset',
'vstutils.models.cent_notify',
Expand Down
8 changes: 4 additions & 4 deletions test_src/test_proj/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -936,7 +936,7 @@ def test_users_api(self):
password2='12345'
)
user_get_request = {"method": "get", "path": ['user', 'profile']}
self.client.force_login(self.user)
self._login()
results = self.bulk([
{"method": "post", "path": ['user', 'profile', 'change_password'], "data": i}
for i in (invalid_old_password, not_identical_passwords, update_password)
Expand Down Expand Up @@ -2525,7 +2525,6 @@ def test_simple_queries(self):
self.assertEqual('get', response[0]['method'])
self.assertEqual('/api/v1/user/1/', response[0]['path'])
self.assertEqual(200, response[0]['status'])
# self.assertEqual('v1', response[0]['version'])

expected_user = user_from_db_to_user_from_api_detail(user1)
actual_user = response[0]['data']
Expand Down Expand Up @@ -3861,12 +3860,13 @@ class FieldChoices(BaseEnum):

@override_settings(SESSION_ENGINE='django.contrib.sessions.backends.db')
def test_hierarchy(self):
# self.client_oauth_session = False
Host.objects.all().delete()
HostGroup.objects.all().delete()
bulk_data = list(self.objects_bulk_data)
results = self.bulk(bulk_data)
for result in results:
self.assertEqual(result['status'], 201, result)
for num, result in enumerate(results):
self.assertEqual(result['status'], 201, f'Attempt: {num}, {result}')
del result
self._check_subhost(results[0]['data']['id'], name='a')
self._check_subhost(
Expand Down
2 changes: 1 addition & 1 deletion vstutils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# pylint: disable=django-not-available
__version__: str = '5.11.11'
__version__: str = '5.11.12'
11 changes: 7 additions & 4 deletions vstutils/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from copy import deepcopy

import pyotp
from django.contrib.auth import get_user_model, update_session_auth_hash
from django.contrib.auth import get_user_model, HASH_SESSION_KEY
from django.contrib.auth.hashers import make_password
from django.contrib.auth.password_validation import validate_password
from django.contrib.auth.models import AbstractUser
Expand Down Expand Up @@ -131,16 +131,17 @@ class ChangePasswordSerializer(BaseSerializer):
password = fields.PasswordField(required=True, label='New password')
password2 = fields.PasswordField(required=True, label='Confirm new password')

@transaction.atomic
def update(self, instance, validated_data):
if not instance.check_password(validated_data['old_password']):
raise exceptions.AuthenticationFailed()
if validated_data['password'] != validated_data['password2']:
raise exceptions.ValidationError(
translate("New passwords values are not equal.")
)
validate_password(validated_data['password'])
validate_password(validated_data['password'], user=instance)
instance.set_password(validated_data['password'])
instance.save()
instance.save(update_fields=['password'])
return instance

def to_representation(self, instance):
Expand Down Expand Up @@ -307,7 +308,9 @@ def change_password(self, request: drf_request.Request, *args, **kwargs):
serializer = self.get_serializer(user, data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
update_session_auth_hash(request, user)
if hasattr(user, "get_session_auth_hash") and request.user == user:
request.session[HASH_SESSION_KEY] = user.get_session_auth_hash()
request.session.save()
return responses.HTTP_201_CREATED(serializer.data)

@deco.action(['get', 'put'], detail=True, permission_classes=(ChangePasswordPermission,))
Expand Down
16 changes: 8 additions & 8 deletions vstutils/api/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,16 +374,16 @@ def original_environ_data(self, request: BulkRequestType, *args) -> _t.Dict:
value = get_environ(env_var, None)
if value:
kwargs[env_var] = str(value)

if request.user.is_authenticated:
if isinstance(request.successful_authenticator, SessionAuthentication):
kwargs['HTTP_COOKIE'] = str(request.META.get('HTTP_COOKIE'))
elif isinstance(request.successful_authenticator, (
BasicAuthentication,
TokenAuthentication,
JWTBearerTokenAuthentication,
)):
kwargs['HTTP_AUTHORIZATION'] = str(request.META.get('HTTP_AUTHORIZATION'))
kwargs['user'] = request.user

if cookies := get_environ('HTTP_COOKIE'):
kwargs['HTTP_COOKIE'] = str(cookies)

if auth_header := get_environ('HTTP_AUTHORIZATION'):
kwargs['HTTP_AUTHORIZATION'] = str(auth_header)

kwargs['language'] = getattr(request, 'language', None)
kwargs['session'] = getattr(request, 'session', None)
kwargs['notificator'] = getattr(request, 'notificator', None)
Expand Down
2 changes: 1 addition & 1 deletion vstutils/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def has_permission(self, request, view):
class SuperUserPermission(IsAuthenticatedOpenApiRequest):

def has_permission(self, request, view):
if request.user.is_staff or request.method in permissions.SAFE_METHODS:
if request.user.is_staff or request.user.is_superuser or request.method in permissions.SAFE_METHODS:
# pylint: disable=bad-super-call
return super(IsAuthenticatedOpenApiRequest, self).has_permission(request, view)
with raise_context():
Expand Down
8 changes: 7 additions & 1 deletion vstutils/oauth2/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,14 @@ def _get_request_token(request: "Request"):
raise AuthenticationFailed() from exc


def _get_session_store(): # nocv
# We have to mock this method in tests
# because performance preferred
return SESSION_STORE


def get_session(session_key):
session = SESSION_STORE(session_key)
session = _get_session_store()(session_key)
session._from_jwt = True # pylint: disable=protected-access
return session

Expand Down
1 change: 1 addition & 0 deletions vstutils/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1656,6 +1656,7 @@ class OauthServerClientConfig(_t.TypedDict):
for storage_name in filter('staticfiles'.__ne__, STORAGES):
STORAGES[storage_name] = {"BACKEND": 'django.core.files.storage.InMemoryStorage'}
CENTRIFUGO_CLIENT_KWARGS = {}
OAUTH_SERVER_TOKEN_EXPIRES_IN = 60 * 60
try:
__import__('pysqlite3')
sys.modules['sqlite3'] = sys.modules.pop('pysqlite3') # nocv
Expand Down
52 changes: 51 additions & 1 deletion vstutils/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
import os # noqa: F401
import uuid
import warnings
from time import time
from importlib import import_module
from unittest.mock import patch, Mock
import json # noqa: F401

import ormsgpack
from authlib.jose import jwt
from django.apps import apps
from django.http import StreamingHttpResponse
from django.db import transaction, models as django_models
from django.core.exceptions import BadRequest
from django.contrib.sessions.backends.base import SessionBase
from django.conf import settings
from django.test import TestCase, override_settings # noqa: F401
from django.contrib.auth import get_user_model
Expand All @@ -21,19 +25,37 @@

from .utils import raise_context_decorator_with_default
from .api.renderers import ORJSONRenderer
from .oauth2.jwk import jwk_set

User = get_user_model()

BulkDataType = _t.Union[_t.List[_t.Dict[_t.Text, _t.Any]], str, bytes, bytearray]
ApiResultType = _t.Union[BulkDataType, _t.Dict, _t.Sequence[BulkDataType]]

patched_get_session = patch("vstutils.oauth2.authentication._get_session_store").start()
patched_get_session.side_effect = lambda: import_module(settings.SESSION_ENGINE).SessionStore


class BaseTestCase(TestCase):
"""
Main test case class extends :class:`django.test.TestCase`.
"""
server_name = 'vstutilstestserver'

#: oAuth2 server class
server_class = import_string(settings.OAUTH_SERVER_CLASS)

#: oAuth2 client id
client_token_app_id = 'simple-client-id'

#: oAuth2 grant type
client_token_grant_type = 'password'

#: oAuth2 scopes
client_token_scopes = 'openid read write'

client_oauth_session = True

#: Attribute with default project models module.
models = None

Expand Down Expand Up @@ -93,15 +115,43 @@ def _create_user(self, is_super_user=True, **kwargs):
user.data = {'username': username, 'password': password}
return user

def get_oauth2_server(self):
return self.server_class()

def generate_token_for_session(self, session: SessionBase):
oauth_server = self.get_oauth2_server()
client = oauth_server.query_client(self.client_token_app_id)
payload = {
'iss': settings.OAUTH_SERVER_ISSUER,
'aud': client.get_client_id(),
'client_id': client.get_client_id(),
'jti': str(session.session_key),
'sub': str(session.get('user_id', None)),
'scope': self.client_token_scopes,
'exp': int(time()) + 3600,
'iat': int(time()),
}
header = {
'alg': settings.OAUTH_SERVER_JWT_ALG,
'typ': 'at+jwt'
}
return jwt.encode(header, payload, key=jwk_set).decode('utf-8')

def _login(self):
client = self.client
client.force_login(self.user)
# TODO: Make OAuth2 auth
# Make OAuth2 auth over session auth
if self.client_oauth_session:
client.session.save()
client.defaults.setdefault('Sec-Fetch-Site', 'same-origin')
client.defaults['HTTP_AUTHORIZATION'] = f'Bearer {self.generate_token_for_session(client.session)}'
return client

def _logout(self, client):
saved_cookies = client.cookies
client.logout()
client.defaults.pop('Sec-Fetch-Site', None)
client.defaults.pop('HTTP_AUTHORIZATION', None)
client.cookies = saved_cookies

def _check_update(self, url, data, **fields):
Expand Down

0 comments on commit 2820532

Please sign in to comment.