diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index f8388d3..a4310bc 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -13,17 +13,8 @@ jobs: max-parallel: 4 matrix: include: - - python-version: 3.7 - django-version: Django==3.1 - - - python-version: 3.7 - django-version: Django==3.2 - - - python-version: 3.8 - django-version: Django==3.1 - - - python-version: 3.8 - django-version: Django==3.2 + - python-version: 3.11 + django-version: Django==4.2 steps: - uses: actions/checkout@v2 @@ -36,7 +27,7 @@ jobs: python -m pip install --upgrade pip pip install ${{ matrix.django-version }} pip install -r example/requirements.txt - pip install -e .[msdal] + pip install -e .[mssso] - name: Flake run: | flake8 --config .config/flake8 --ignore E1,E23,W503 auth_token diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml new file mode 100644 index 0000000..19b8b69 --- /dev/null +++ b/.github/workflows/publish-to-pypi.yml @@ -0,0 +1,35 @@ +name: Build and publish package to PyPI + +on: push + +jobs: + build-n-publish: + name: Build and publish package to PyPI + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@master + - name: Set up Python 3.11 + uses: actions/setup-python@v3 + with: + python-version: "3.11" + - name: Install pypa/build + run: >- + python -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: >- + python -m + build + --sdist + --wheel + --outdir dist/ + . + - name: Publish package to PyPI + if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + verify_metadata: false diff --git a/auth_token/config.py b/auth_token/config.py index 7c4d6fb..92ab137 100644 --- a/auth_token/config.py +++ b/auth_token/config.py @@ -62,8 +62,10 @@ 'EXPIRATION_DELTA': 0, # Authorization token expiration will be extended, only if original expiration is at # least "X" seconds older, than new one (default: 0 seconds, i.e. always extend) 'TAKEOVER_ENABLED': True, # Turns on/off takeover functionality - 'MS_SSO_APP_ID': None, # Set AppID for MS SSO authentication - 'MS_SSO_TENANT_ID': None, # Set TentnatID for MS SSO authentication + 'MS_SSO_PROTOCOL': None, # Protocol of SSO (possible values are "oauth" or "saml") + 'MS_SSO_APP_ID': None, # Set AppID for MS SSO authentication (OAuth only) + 'MS_SSO_TENANT_ID': None, # Set TentnatID for MS SSO authentication (OAuth only) + 'MS_SSO_METADATA_URL': None, # Set Metadata URL for MS SSO authentication (SAML only) } diff --git a/auth_token/contrib/common/default/views.py b/auth_token/contrib/common/default/views.py index 88014a7..bc32a7f 100644 --- a/auth_token/contrib/common/default/views.py +++ b/auth_token/contrib/common/default/views.py @@ -1,3 +1,5 @@ +from urllib.parse import quote_plus + from django.contrib.auth.views import LoginView, LogoutView from django.utils.decorators import method_decorator from django.urls import reverse, NoReverseMatch @@ -40,7 +42,7 @@ def _get_sso_login_methods(self): return [ { 'name': 'microsoft', - 'url': f'{reverse("ms-sso-login")}?next={self.request.GET.get("next", "/")}', + 'url': f'{reverse("ms-sso-login")}?next={quote_plus(self.request.GET.get("next", "/"), safe="/")}', 'label': gettext('Continue with Microsoft account') } ] @@ -58,11 +60,7 @@ class TokenLogoutView(LogoutView): @method_decorator(never_cache) def dispatch(self, request, *args, **kwargs): logout(request) - next_page = self.get_next_page() - if next_page: - # Redirect to this page until the session has been cleared. - return HttpResponseRedirect(next_page) - return super(LogoutView, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) class InputLogMixin: diff --git a/auth_token/contrib/is_core_auth/views.py b/auth_token/contrib/is_core_auth/views.py index 6a072f1..9cafabd 100644 --- a/auth_token/contrib/is_core_auth/views.py +++ b/auth_token/contrib/is_core_auth/views.py @@ -8,8 +8,11 @@ from django.views.generic.base import RedirectView from django.urls import reverse +from security.decorators import throttling_all + import import_string from auth_token.config import settings +from auth_token.contrib.common.auth_security.validators import LOGIN_THROTTLING_VALIDATORS from auth_token.contrib.common.views import LoginView as _LoginView from auth_token.contrib.common.views import LogoutView as _LogoutView from auth_token.contrib.common.views import LoginCodeVerificationView as _LoginCodeVerificationView @@ -85,6 +88,7 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) +@throttling_all(*LOGIN_THROTTLING_VALIDATORS) class LoginCodeVerificationView(_LoginCodeVerificationView): template_name = 'is_core/login.html' diff --git a/auth_token/contrib/ms_sso/backends.py b/auth_token/contrib/ms_sso/backends.py index cfb30d6..f5869af 100644 --- a/auth_token/contrib/ms_sso/backends.py +++ b/auth_token/contrib/ms_sso/backends.py @@ -6,29 +6,64 @@ UserModel = get_user_model() - -class MsSsoBackend(ModelBackend): +class BaseMsSsoBackend(ModelBackend): """ Authenticates device with MS SSO """ - def _get_user_from_ms_user_data(self, ms_user_data): - username = ms_user_data['userPrincipalName'] + def _get_natural_key(self): + raise NotImplementedError + + def _get_user_from_natural_key(self, natural_key): try: - return UserModel._default_manager.get_by_natural_key(username) + return UserModel._default_manager.det_by_natural_key(self._get_natural_key()) except UserModel.DoesNotExist: return None - def authenticate(self, request, mso_token=None, **kwargs): + def authenticate(self, request, **kwargs): if not mso_token: return None - ms_user_data = get_user_data(mso_token) - if not ms_user_data: + self.ms_user_data = get_user_data(mso_token) + if not self.ms_user_data: return None - user = self._get_user_from_ms_user_data(ms_user_data) + user = self._get_user_from_natural_key() if user and self.user_can_authenticate(user): return user else: return None + +class MsSsoOauthBackend(ModelBackend): + """ + Authenticates device with MS SSO + """ + + def _get_natural_key(self): + username = self.ms_user_data['userPrincipalName'] + try: + return UserModel._default_manager.get_by_natural_key(username) + except UserModel.DoesNotExist: + return None + + def authenticate(self, request, mso_token=None, **kwargs): + if not mso_token: + return None + + self.ms_user_data = get_user_data(mso_token) + if not self.ms_user_data: + return None + + return super().authenticate(request) + + +class MsSsoSamlBackend(ModelBackend): + """ + Authenticates device with MS SSO + """ + + def _get_natural_key(self): + return "ABC" + + def authenticate(self, request, mso_token=None, **kwargs): + self.request = request diff --git a/auth_token/contrib/ms_sso/urls.py b/auth_token/contrib/ms_sso/urls.py index 80eeb26..be8db49 100644 --- a/auth_token/contrib/ms_sso/urls.py +++ b/auth_token/contrib/ms_sso/urls.py @@ -1,17 +1,37 @@ from django.urls import path -from .views import MsCallback, MsLogin +from .views import MsOauthLogin, MsSamlLogin, MsSamlCallback, MsOauthCallback +from auth_token.config import settings + + +def _get_view(key): + VIEWS = { + "oauth": { + "login": MsOauthLogin, + "callback": MsOauthCallback, + }, + "saml": { + "login": MsSamlLogin, + "callback": MsSamlCallback, + } + } + protocol = settings.MS_SSO_PROTOCOL + if settings.MS_SSO_PROTOCOL not in VIEWS.keys(): + raise ValueError("MS SSO Protocol \"{protocol}\" is not supported.") + + return VIEWS[protocol][key] + urlpatterns = [ path( 'login/mso', - MsLogin.as_view(), + _get_view("login").as_view(), name='ms-sso-login', ), path( 'login/mso/callback', - MsCallback.as_view(), + _get_view("callback").as_view(), name='ms-sso-redirect', ), ] diff --git a/auth_token/contrib/ms_sso/views.py b/auth_token/contrib/ms_sso/views.py index 65d7764..eae75f8 100644 --- a/auth_token/contrib/ms_sso/views.py +++ b/auth_token/contrib/ms_sso/views.py @@ -11,7 +11,7 @@ from .helpers import get_sign_in_flow, acquire_token_by_auth_code_flow -class MsLogin(RedirectView): +class MsOauthLogin(RedirectView): def get_redirect_url(self, *args, **kwargs): sign_flow = get_sign_in_flow() @@ -23,26 +23,19 @@ def get_redirect_url(self, *args, **kwargs): return sign_flow['auth_uri'] -class MsCallback(RedirectView): +class MsSamlLogin(RedirectView): - allowed_cookie = True - allowed_header = False + def get_redirect_url(self, *args, **kwargs): + return "http://placeholder" - def get(self, *args, **kwargs): - if not hasattr(self.request, 'session'): - raise ImproperlyConfigured('Django SessionMiddleware must be enabled to use MS SSO') - sign_flow = self.request.session.get('auth_token_ms_sso_auth_flow') - if not sign_flow: - messages.error(self.request, gettext('Microsoft SSO login was unsuccessful, please try it again')) - return redirect_to_login('') +class BaseMsCallback(RedirectView): - result = acquire_token_by_auth_code_flow(sign_flow, self.request.GET) - if 'access_token' not in result: - messages.error(self.request, gettext('Microsoft SSO login was unsuccessful, please try it again')) - return redirect_to_login(sign_flow['next']) + allowed_cookie = True + allowed_header = False - user = authenticate(mso_token=result['access_token']) + def _do_login(self, **kwargs): + user = authenticate(**kwargs) if not user: messages.error( self.request, gettext('Microsoft SSO login was unsuccessful, please use another login method') @@ -57,3 +50,28 @@ def get(self, *args, **kwargs): two_factor_login=False ) return HttpResponseRedirect(sign_flow['next']) + + +class MsOauthCallback(BaseMsCallback): + + def get(self, *args, **kwargs): + if not hasattr(self.request, 'session'): + raise ImproperlyConfigured('Django SessionMiddleware must be enabled to use MS SSO') + + sign_flow = self.request.session.get('auth_token_ms_sso_auth_flow') + if not sign_flow: + messages.error(self.request, gettext('Microsoft SSO login was unsuccessful, please try it again')) + return redirect_to_login('') + + result = acquire_token_by_auth_code_flow(sign_flow, self.request.GET) + if 'access_token' not in result: + messages.error(self.request, gettext('Microsoft SSO login was unsuccessful, please try it again')) + return redirect_to_login(sign_flow['next']) + + return _do_login(mso_token=result['access_token']) + + +class MsSamlCallback(BaseMsCallback): + + def post(self, *args, **kwargs): + return self._do_login(saml_callback_request=self.request) diff --git a/auth_token/contrib/rest_framework_auth/authentication.py b/auth_token/contrib/rest_framework_auth/authentication.py index acb921b..2f71e73 100644 --- a/auth_token/contrib/rest_framework_auth/authentication.py +++ b/auth_token/contrib/rest_framework_auth/authentication.py @@ -33,7 +33,10 @@ def enforce_csrf(self, request): """ Enforce CSRF validation for session based authentication. """ - reason = CSRFCheck().process_view(request, None, (), {}) + + def dummy_get_response(_): # pragma: no cover + return None + reason = CSRFCheck(dummy_get_response).process_view(request, None, (), {}) if reason: # CSRF failed, bail with explicit error message raise exceptions.PermissionDenied('CSRF Failed: %s' % reason) diff --git a/auth_token/middleware.py b/auth_token/middleware.py index 11c5465..9d67556 100644 --- a/auth_token/middleware.py +++ b/auth_token/middleware.py @@ -1,7 +1,7 @@ from datetime import timedelta import time -from django.utils.encoding import force_text +from django.utils.encoding import force_str from django.utils.functional import SimpleLazyObject from django.utils.http import http_date @@ -47,7 +47,7 @@ def _update_token_and_cookie(self, request, response, max_age, expires): if expires_at - request.token.expires_at >= timedelta(seconds=settings.EXPIRATION_DELTA): request.token.change_and_save(expires_at=expires_at, update_only_changed_fields=True) if settings.COOKIE and request.token.allowed_cookie: - response.set_cookie(settings.COOKIE_NAME, force_text(request.token.secret_key), max_age=max_age, + response.set_cookie(settings.COOKIE_NAME, force_str(request.token.secret_key), max_age=max_age, expires=expires, httponly=settings.COOKIE_HTTPONLY, secure=settings.COOKIE_SECURE, domain=settings.COOKIE_DOMAIN) return response diff --git a/auth_token/models.py b/auth_token/models.py index 1fc7687..ce9e322 100644 --- a/auth_token/models.py +++ b/auth_token/models.py @@ -329,6 +329,11 @@ def _pre_save(self, changed, changed_fields, *args, **kwargs): is_primary=False ) + def _post_save(self, changed, changed_fields, *args, **kwargs): + super()._post_save(changed, changed_fields, *args, **kwargs) + if 'is_active' in changed_fields and not self.is_active: + self.authorization_tokens.update(is_active=False) + def set_login_token(self, raw_token): self.login_token = make_password(raw_token) diff --git a/auth_token/utils.py b/auth_token/utils.py index 0620f4c..1caf661 100644 --- a/auth_token/utils.py +++ b/auth_token/utils.py @@ -9,7 +9,7 @@ import import_string -from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.conf import settings as django_settings from django.contrib.auth import _get_backends, load_backend from django.contrib.auth.models import AnonymousUser @@ -86,7 +86,7 @@ def hash_key(key, salt=None): settings.HASH_SALT, key, secret='|'.join((salt or '', django_settings.SECRET_KEY)), - algorithm=django_settings.DEFAULT_HASHING_ALGORITHM, + algorithm="sha256", ).hexdigest() @@ -273,7 +273,7 @@ def parse_auth_header_value(request): header_value = request.META.get(header_name_to_django(settings.HEADER_NAME)) if not header_value: - raise ValueError('Authorization header missing') + raise PermissionDenied("Authorization header missing") if settings.HEADER_TOKEN_TYPE is None: return header_value diff --git a/auth_token/version.py b/auth_token/version.py index 8fb2b60..655071d 100644 --- a/auth_token/version.py +++ b/auth_token/version.py @@ -1,4 +1,4 @@ -VERSION = (0, 2, 15) +VERSION = (0, 4, 0) def get_version(): diff --git a/example/Makefile b/example/Makefile index 28adfb9..6dbcf6b 100644 --- a/example/Makefile +++ b/example/Makefile @@ -38,7 +38,7 @@ cleanall: cleanvar pip: $(PYTHON_BIN)/pip install -r requirements.txt - $(PYTHON_BIN)/pip install -e ../.[msdal] + $(PYTHON_BIN)/pip install -e ../.[mssso] initvirtualenv: virtualenv -p $(PYTHON) $(VIRTUAL_ENV) diff --git a/example/dj/apps/app/tests/is_core.py b/example/dj/apps/app/tests/is_core.py index 6ffa5be..433b82f 100644 --- a/example/dj/apps/app/tests/is_core.py +++ b/example/dj/apps/app/tests/is_core.py @@ -4,7 +4,7 @@ from django.contrib.auth.hashers import make_password from django.test import override_settings from django.utils import timezone -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from nose.tools import assert_equal from auth_token.config import settings diff --git a/example/dj/apps/app/tests/models.py b/example/dj/apps/app/tests/models.py index bd78eed..5514e71 100644 --- a/example/dj/apps/app/tests/models.py +++ b/example/dj/apps/app/tests/models.py @@ -88,3 +88,23 @@ def test_mobile_device_login_token_should_be_updated_with_set_login_token(self, assert_true(mobile_device.login_token.startswith('pbkdf2_sha256')) assert_true(mobile_device.check_login_token('test')) + @data_consumer('create_user') + def test_deactivation_of_mobile_device_should_deactivate_its_authorization_tokens(self, user): + mobile_device = MobileDevice.objects.activate_or_create(uuid4, user, is_primary=True) + auth_token = AuthorizationToken.objects.create( + user=user, ip='127.0.0.1', backend='test', mobile_device=mobile_device + ) + + # make sure everything is active + assert_true(mobile_device.is_active) + assert_true(auth_token.refresh_from_db().is_active) + + # deactivating mobile device must deactivate its authorization tokens + mobile_device.change_and_save(is_active=False) + assert_false(mobile_device.is_active) + assert_false(auth_token.refresh_from_db().is_active) + + # activating mobile device must not activate its authorization tokens + mobile_device.change_and_save(is_active=True) + assert_true(mobile_device.is_active) + assert_false(auth_token.refresh_from_db().is_active) diff --git a/example/dj/apps/app/tests/utils.py b/example/dj/apps/app/tests/utils.py index ca4bfb8..cd19cd4 100644 --- a/example/dj/apps/app/tests/utils.py +++ b/example/dj/apps/app/tests/utils.py @@ -5,6 +5,7 @@ from unittest import mock +from django.core.exceptions import PermissionDenied from freezegun import freeze_time from django.core.management import call_command @@ -168,7 +169,7 @@ def test_parse_auth_header_value_should_return_token(self): def test_parse_auth_header_missing_value_should_raise_exception(self): request = self.requiest_factory.get('/') - with assert_raises(ValueError): + with assert_raises(PermissionDenied): parse_auth_header_value(request) def test_parse_auth_header_invalid_value_should_return_none(self): diff --git a/example/dj/settings/base.py b/example/dj/settings/base.py index 216b8b8..ec2a032 100644 --- a/example/dj/settings/base.py +++ b/example/dj/settings/base.py @@ -2,7 +2,7 @@ import os try: - from django.utils.translation import ugettext_lazy as _ + from django.utils.translation import gettext_lazy as _ except ImportError: def _(val): return val diff --git a/example/dj/urls.py b/example/dj/urls.py index 65556a4..7c6a5e1 100644 --- a/example/dj/urls.py +++ b/example/dj/urls.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.conf.urls import url, include +from django.urls import re_path as url, include from django.contrib import admin from app.resource import SimpleAPI diff --git a/example/requirements.txt b/example/requirements.txt index 870e309..d33e36d 100644 --- a/example/requirements.txt +++ b/example/requirements.txt @@ -1,8 +1,8 @@ -Django<=3.2 -django-germanium==2.3.0 +Django~=4.2.0 +skip-django-germanium==2.4.0 coveralls flake8 -django-is-core==2.24.0 -djangorestframework==3.12.2 +skip-django-is-core==2.25.0 +djangorestframework==3.14.0 freezegun==1.0.0 responses==0.22.0 diff --git a/setup.py b/setup.py index 4f03c0d..1da0552 100644 --- a/setup.py +++ b/setup.py @@ -3,13 +3,13 @@ from auth_token.version import get_version setup( - name='django-auth-token', + name='skip-django-auth-token', version=get_version(), description="Django authorization via tokens.", keywords='django, authorization', author='Lubos Matl', author_email='matllubos@gmail.com', - url='https://github.com/druids/django-auth-token', + url='https://github.com/skip-pay/django-auth-token', license='BSD', package_dir={'auth_token': 'auth_token'}, include_package_data=True, @@ -24,12 +24,13 @@ 'Topic :: Internet :: WWW/HTTP', ], install_requires=[ - 'django>=2.2.14, <4.0', + 'django>=4.2.0', 'django-ipware>=3.0.2', 'import_string==0.1.0', - 'django-chamber>=0.6.14', - 'django-generic-m2m-field>=0.0.4', - 'django-choice-enumfields>=1.1.0', + 'skip-django-chamber>=0.7.2', + 'skip-django-generic-m2m-field>=0.1.0', + 'skip-django-choice-enumfields>=1.1.3.2', + 'skip-django-security-logger>=1.7.0', 'tqdm>=4.62.3', ], extras_require={