From f7887931983404f433d7d9620047c96a577af670 Mon Sep 17 00:00:00 2001 From: Sergei Kliuikov Date: Tue, 19 Nov 2024 12:23:03 -0800 Subject: [PATCH] Release 5.11.13 ### Changelog: - Feature(backend): Add support methods for test cases for login and logout. - Feature(backend): Provide overriding ``UserWrapper`` class. - Fix(backend): Default OID client id and other stuff from settings. - Fix(backend): Performance improvement for bulk requests on JWT auth. - Chore(deps): Bump cross-spawn from 7.0.3 to 7.0.6. --- .pylintrc | 2 +- test_src/test_proj/oauth_user.py | 7 +++++++ test_src/test_proj/settings.py | 2 ++ test_src/test_proj/tests.py | 16 +++++++++------- vstutils/__init__.py | 2 +- vstutils/api/endpoint.py | 7 ++++++- vstutils/oauth2/authentication.py | 6 ++++++ vstutils/oauth2/authorization_code.py | 4 +++- vstutils/oauth2/authorization_server.py | 2 +- vstutils/oauth2/endpoints.py | 2 +- vstutils/settings.py | 1 + vstutils/tests.py | 17 +++++++++++++++-- yarn.lock | 6 +++--- 13 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 test_src/test_proj/oauth_user.py diff --git a/.pylintrc b/.pylintrc index f82f1840..34d824e7 100644 --- a/.pylintrc +++ b/.pylintrc @@ -313,7 +313,7 @@ max-attributes=7 min-public-methods=2 # Maximum number of public methods for a class (see R0904). -max-public-methods=25 +max-public-methods=30 # Maximum number of boolean expressions in a if statement max-bool-expr=5 diff --git a/test_src/test_proj/oauth_user.py b/test_src/test_proj/oauth_user.py new file mode 100644 index 00000000..3d2fc8b9 --- /dev/null +++ b/test_src/test_proj/oauth_user.py @@ -0,0 +1,7 @@ +from vstutils.oauth2.user import UserWrapper + +class OAuthUser(UserWrapper): + def get_profile_claims(self) -> dict: + claims = super().get_profile_claims() + claims['test_value'] = 'test_value' + return claims diff --git a/test_src/test_proj/settings.py b/test_src/test_proj/settings.py index 70e4836b..7ee76b04 100644 --- a/test_src/test_proj/settings.py +++ b/test_src/test_proj/settings.py @@ -209,3 +209,5 @@ 'allowed_redirect_uris': 'https://some-app.com/auth-callback', 'allowed_response_types': ['code'], } + +OAUTH_SERVER_USER_WRAPPER = 'test_proj.oauth_user.OAuthUser' diff --git a/test_src/test_proj/tests.py b/test_src/test_proj/tests.py index b75923af..ccfc41a1 100644 --- a/test_src/test_proj/tests.py +++ b/test_src/test_proj/tests.py @@ -936,7 +936,7 @@ def test_users_api(self): password2='12345' ) user_get_request = {"method": "get", "path": ['user', 'profile']} - self._login() + self.login_user() results = self.bulk([ {"method": "post", "path": ['user', 'profile', 'change_password'], "data": i} for i in (invalid_old_password, not_identical_passwords, update_password) @@ -951,7 +951,7 @@ def test_users_api(self): self.assertEqual(results[0]['status'], 200) self.assertEqual(results[0]['data']['username'], self.user.username) - self._logout(self.client) + self.logout_user() self.change_identity(True) data = [ @@ -1501,7 +1501,7 @@ def test_bulk(self): headers={'accept-language': 'ru,en-US;q=0.9,en;q=0.8,ru-RU;q=0.7,es;q=0.6'}, relogin=False ) - self._logout(self.client) + self.logout_user() self.assertEqual(result[0]['status'], 204) self.assertEqual(result[1]['status'], 404) @@ -1602,7 +1602,7 @@ def test_get_openapi(self): del expected['some_barcode128'] self.assertDictEqual(expected, from_api) # Test swagger ui - client = self._login() + client = self.login_user() response = client.get('/api/endpoint/') self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'drf-yasg/swagger-ui.html') @@ -2416,7 +2416,7 @@ def test_etag(self): response3 = client.get('/api/endpoint/?format=openapi&version=v2', headers=headers) self.assertEqual(response3.status_code, 200) - self._login() + self.login_user() headers['Cookie'] = f'{settings.SESSION_COOKIE_NAME}={self.client.session.session_key}; lang=en' response4 = client.get('/api/endpoint/?format=openapi', headers=headers) self.assertEqual(response4.status_code, 200, response4.content) @@ -3414,7 +3414,7 @@ def test_user_language_detection(self): self.assertEqual(to_soup(response.content).html['lang'], 'en') def test_server_translation(self): - self._logout(self.client) + self.logout_user() bulk_data = [ dict(path=['_lang', 'ru'], method='get'), ] @@ -5893,7 +5893,7 @@ class WebSocketTestCase(BaseTestCase): def setUp(self): super().setUp() - self.client = self._login() + self.client = self.login_user() self.cookie = f"{self.client.cookies.output(header='', sep='; ').strip()};" self.cookie += f"csrftoken={_get_new_csrf_token()}" @@ -7087,6 +7087,7 @@ def test_anon_user_login(self): self.assertDictEqual(response.json(), { "sub": access_token_jwt['sub'], "anon": True, + "test_value": "test_value", }) def test_schema(self): @@ -7319,6 +7320,7 @@ def test_userinfo(self): "family_name": "Doe", "preferred_username": "j.doe", "email": "janedoe@example.com", + "test_value": "test_value", }) def test_auth_with_scope(self): diff --git a/vstutils/__init__.py b/vstutils/__init__.py index 4d1e00eb..8de3c6f7 100644 --- a/vstutils/__init__.py +++ b/vstutils/__init__.py @@ -1,2 +1,2 @@ # pylint: disable=django-not-available -__version__: str = '5.11.12' +__version__: str = '5.11.13' diff --git a/vstutils/api/endpoint.py b/vstutils/api/endpoint.py index 2bc605a9..4a070973 100644 --- a/vstutils/api/endpoint.py +++ b/vstutils/api/endpoint.py @@ -150,6 +150,7 @@ def request_handler(self, request: HttpRequest) -> HttpRequest: request.user = request.META.pop('user') # pylint: disable=protected-access request._cached_user = request.user # type: ignore + request.auth_obj = request.META.pop('auth_obj') if 'language' in request.META: request.language = request.META.pop('language') # type: ignore if 'session' in request.META: @@ -175,6 +176,7 @@ class BulkClient(Client): def __init__(self, enforce_csrf_checks=False, **defaults): # pylint: disable=bad-super-call self.user = defaults.pop('user', None) + self.auth_obj = defaults.pop('auth_obj', None) self.language = defaults.pop('language', None) self.session = defaults.pop('session', None) self.notificator = defaults.pop('notificator', None) @@ -184,6 +186,8 @@ def __init__(self, enforce_csrf_checks=False, **defaults): def request(self, **request): if self.user: request['user'] = self.user + if self.auth_obj: + request['auth_obj'] = self.auth_obj if self.language: request['language'] = self.language if self.session: @@ -369,7 +373,7 @@ def get_client(self, request: BulkRequestType) -> BulkClient: def original_environ_data(self, request: BulkRequestType, *args) -> _t.Dict: get_environ = request.META.get - kwargs: _t.Dict[str, _t.Optional[_t.Any]] = {} + kwargs: _t.Dict[str, _t.Optional[_t.Any]] = {"auth_obj": None} for env_var in tuple(self.client_environ_keys_copy) + args: value = get_environ(env_var, None) if value: @@ -377,6 +381,7 @@ def original_environ_data(self, request: BulkRequestType, *args) -> _t.Dict: if request.user.is_authenticated: kwargs['user'] = request.user + kwargs['auth_obj'] = request.successful_authenticator if cookies := get_environ('HTTP_COOKIE'): kwargs['HTTP_COOKIE'] = str(cookies) diff --git a/vstutils/oauth2/authentication.py b/vstutils/oauth2/authentication.py index c1469585..67e85a0d 100644 --- a/vstutils/oauth2/authentication.py +++ b/vstutils/oauth2/authentication.py @@ -59,7 +59,13 @@ def get_session(session_key): class JWTBearerTokenAuthentication(BaseAuthentication): def authenticate(self, request: "Request"): + # pylint: disable=protected-access + if getattr(request._request, "is_bulk", False) and getattr(request, "auth_obj", 0).__class__ is self.__class__: + # Hack for Bulk requests performance + return request._request.user, request.auth_obj.token + if token := _get_request_token(request): + self.token = token request._request.session = get_session(token['jti']) # pylint: disable=protected-access self.patch_vary(request) return get_user(request._request), token # pylint: disable=protected-access diff --git a/vstutils/oauth2/authorization_code.py b/vstutils/oauth2/authorization_code.py index b720cfc4..a565e66a 100644 --- a/vstutils/oauth2/authorization_code.py +++ b/vstutils/oauth2/authorization_code.py @@ -12,16 +12,18 @@ from django.conf import settings from django.contrib.auth import load_backend from django.core.cache import caches +from django.utils.module_loading import import_string from .client import SimpleClient from .requests import DjangoOAuth2Request -from .user import UserWrapper from .jwk import jwk_set _auth_code_cache = caches[settings.OAUTH_SERVER_AUTHORIZATION_CODE_CACHE_NAME] _auth_code_ttl = 300 +UserWrapper = import_string(settings.OAUTH_SERVER_USER_WRAPPER) + @dataclass class AuthorizationCode(AuthorizationCodeMixin): diff --git a/vstutils/oauth2/authorization_server.py b/vstutils/oauth2/authorization_server.py index cda8ead9..e8124c6f 100644 --- a/vstutils/oauth2/authorization_server.py +++ b/vstutils/oauth2/authorization_server.py @@ -39,7 +39,6 @@ from .client import query_simple_client, SimpleClient from .jwk import jwk_set from .requests import DjangoOAuth2Request, DjangoOAuthJsonRequest -from .user import UserWrapper if TYPE_CHECKING: # nocv from authlib.jose import KeySet @@ -47,6 +46,7 @@ SESSION_STORE = get_session_store() +UserWrapper = import_string(settings.OAUTH_SERVER_USER_WRAPPER) extra_claims_provider: 'Optional[Callable[[AbstractBaseUser], Optional[dict]]]' = ( import_string(settings.OAUTH_SERVER_JWT_EXTRA_CLAIMS_PROVIDER) if settings.OAUTH_SERVER_JWT_EXTRA_CLAIMS_PROVIDER diff --git a/vstutils/oauth2/endpoints.py b/vstutils/oauth2/endpoints.py index 8769375e..5953fe86 100644 --- a/vstutils/oauth2/endpoints.py +++ b/vstutils/oauth2/endpoints.py @@ -15,13 +15,13 @@ from vstutils.api.responses import HTTP_200_OK from .authentication import JWTBearerTokenAuthentication -from .user import UserWrapper if TYPE_CHECKING: from authlib.oauth2.rfc6749 import AuthorizationServer # nocv ServerClass = import_string(settings.OAUTH_SERVER_CLASS) server: "AuthorizationServer" = ServerClass() +UserWrapper = import_string(settings.OAUTH_SERVER_USER_WRAPPER) class Oauth2Throttle(AnonRateThrottle): diff --git a/vstutils/settings.py b/vstutils/settings.py index 72df570f..016dafdf 100644 --- a/vstutils/settings.py +++ b/vstutils/settings.py @@ -1604,6 +1604,7 @@ class OauthServerClientConfig(_t.TypedDict): 'default_redirect_uri': None, } } +OAUTH_SERVER_USER_WRAPPER = 'vstutils.oauth2.user.UserWrapper' if OAUTH_SERVER_ENABLE: if config['oauth']['server_allow_insecure']: diff --git a/vstutils/tests.py b/vstutils/tests.py index 41fef8d3..691e5078 100644 --- a/vstutils/tests.py +++ b/vstutils/tests.py @@ -46,10 +46,10 @@ class BaseTestCase(TestCase): server_class = import_string(settings.OAUTH_SERVER_CLASS) #: oAuth2 client id - client_token_app_id = 'simple-client-id' + client_token_app_id = list(settings.OAUTH_SERVER_CLIENTS.keys())[0] #: oAuth2 grant type - client_token_grant_type = 'password' + client_token_grant_type = settings.OAUTH_SERVER_CLIENTS[client_token_app_id]['allowed_grant_types'][0] #: oAuth2 scopes client_token_scopes = 'openid read write' @@ -137,6 +137,19 @@ def generate_token_for_session(self, session: SessionBase): } return jwt.encode(header, payload, key=jwk_set).decode('utf-8') + def login_user(self, user=None, client=None): + client = client or self.client + client.force_login(user or self.user) + # 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_user(self, client=None): + self._logout(client or self.client) + def _login(self): client = self.client client.force_login(self.user) diff --git a/yarn.lock b/yarn.lock index 0d68d40c..15c6e07e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1693,9 +1693,9 @@ cross-fetch@^4.0.0: node-fetch "^2.6.12" cross-spawn@^7.0.0, cross-spawn@^7.0.2: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0"