From 0be90e6a661de51bf9e95744322060f33dafa347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 14 Jul 2021 11:54:53 +0200 Subject: [PATCH] feat(auth): new auth module (tg-4625, tgg-626) --- CHANGELOG.md | 2 +- requirements-devel.in | 1 + requirements-devel.txt | 24 +- settings/common.py | 17 +- taiga/auth/__init__.py | 25 +- taiga/auth/api.py | 100 ++-- taiga/auth/authentication.py | 166 +++++++ taiga/auth/backends.py | 139 ++++-- taiga/auth/compat.py | 84 ++++ taiga/auth/exceptions.py | 70 +++ taiga/auth/models.py | 136 ++++++ taiga/auth/permissions.py | 4 +- taiga/auth/serializers.py | 105 +++++ taiga/auth/services.py | 164 ++++--- taiga/auth/settings.py | 304 +++++++++++++ taiga/auth/state.py | 36 ++ taiga/auth/throttling.py | 4 +- taiga/auth/token_denylist/__init__.py | 32 ++ taiga/auth/token_denylist/admin.py | 122 +++++ taiga/auth/token_denylist/apps.py | 75 +++ .../token_denylist/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/flushexpiredtokens.py | 43 ++ .../token_denylist/migrations/0001_initial.py | 50 ++ .../token_denylist/migrations/__init__.py | 0 taiga/auth/token_denylist/models.py | 77 ++++ taiga/auth/token_denylist/tasks.py | 17 + taiga/auth/tokens.py | 341 ++++++++++++-- taiga/auth/utils.py | 63 +++ taiga/auth/validators.py | 53 --- taiga/base/api/generics.py | 2 +- taiga/celery.py | 8 + taiga/front/urls.py | 2 +- taiga/projects/services/invitations.py | 44 +- taiga/urls.py | 1 - taiga/users/api.py | 17 +- taiga/users/models.py | 5 - taiga/users/services.py | 2 +- taiga/users/validators.py | 2 +- tests/config.py | 2 + .../test_auth_resources.py | 24 + .../{test_auth_api.py => test_auth.py} | 371 +++++++++++---- tests/integration/test_projects.py | 7 +- tests/integration/test_users.py | 30 +- tests/unit/{conftest.py => auth/__init__.py} | 5 - tests/unit/auth/test_authentication.py | 222 +++++++++ tests/unit/auth/test_backends.py | 319 +++++++++++++ tests/unit/auth/test_token_denylist.py | 191 ++++++++ tests/unit/auth/test_tokens.py | 430 ++++++++++++++++++ tests/unit/auth/utils.py | 187 ++++++++ tests/unit/test_auth_services.py | 31 -- tests/unit/test_export.py | 14 +- tests/unit/test_slug.py | 15 +- tests/unit/test_tokens.py | 43 -- 54 files changed, 3757 insertions(+), 471 deletions(-) create mode 100644 taiga/auth/authentication.py create mode 100644 taiga/auth/compat.py create mode 100644 taiga/auth/exceptions.py create mode 100644 taiga/auth/models.py create mode 100644 taiga/auth/serializers.py create mode 100644 taiga/auth/settings.py create mode 100644 taiga/auth/state.py create mode 100644 taiga/auth/token_denylist/__init__.py create mode 100644 taiga/auth/token_denylist/admin.py create mode 100644 taiga/auth/token_denylist/apps.py create mode 100644 taiga/auth/token_denylist/management/__init__.py create mode 100644 taiga/auth/token_denylist/management/commands/__init__.py create mode 100644 taiga/auth/token_denylist/management/commands/flushexpiredtokens.py create mode 100644 taiga/auth/token_denylist/migrations/0001_initial.py create mode 100644 taiga/auth/token_denylist/migrations/__init__.py create mode 100644 taiga/auth/token_denylist/models.py create mode 100644 taiga/auth/token_denylist/tasks.py create mode 100644 taiga/auth/utils.py delete mode 100644 taiga/auth/validators.py rename tests/integration/{test_auth_api.py => test_auth.py} (65%) rename tests/unit/{conftest.py => auth/__init__.py} (73%) create mode 100644 tests/unit/auth/test_authentication.py create mode 100644 tests/unit/auth/test_backends.py create mode 100644 tests/unit/auth/test_token_denylist.py create mode 100644 tests/unit/auth/test_tokens.py create mode 100644 tests/unit/auth/utils.py delete mode 100644 tests/unit/test_auth_services.py delete mode 100644 tests/unit/test_tokens.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e04e7da7..32ed56ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 6.3.0 (unreleased) -- ... +- New Auth module, based on djangorestframework-simplejwt (history #tg-4625, issue #tgg-626)) ## 6.2.2 (2021-07-15) diff --git a/requirements-devel.in b/requirements-devel.in index be5a6cdb..17159b22 100644 --- a/requirements-devel.in +++ b/requirements-devel.in @@ -4,3 +4,4 @@ coveralls pytest pytest-django factory-boy +python-jose>=3.0.0 diff --git a/requirements-devel.txt b/requirements-devel.txt index 5b694bcb..c20635ce 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -26,6 +26,8 @@ docopt==0.6.2 # via # -c requirements.txt # coveralls +ecdsa==0.17.0 + # via python-jose factory-boy==3.2.0 # via -r requirements-devel.in faker==8.6.0 @@ -34,11 +36,6 @@ idna==2.8 # via # -c requirements.txt # requests -importlib-metadata==4.5.0 - # via - # -c requirements.txt - # pluggy - # pytest iniconfig==1.1.1 # via pytest packaging==20.9 @@ -49,6 +46,10 @@ pluggy==0.13.1 # via pytest py==1.10.0 # via pytest +pyasn1==0.4.8 + # via + # python-jose + # rsa pyparsing==2.4.7 # via # -c requirements.txt @@ -63,27 +64,24 @@ python-dateutil==2.7.5 # via # -c requirements.txt # faker +python-jose==3.3.0 + # via -r requirements-devel.in requests==2.21.0 # via # -c requirements.txt # coveralls +rsa==4.7.2 + # via python-jose six==1.16.0 # via # -c requirements.txt + # ecdsa # python-dateutil text-unidecode==1.3 # via faker toml==0.10.2 # via pytest -typing-extensions==3.10.0.0 - # via - # -c requirements.txt - # importlib-metadata urllib3==1.24.3 # via # -c requirements.txt # requests -zipp==1.2.0 - # via - # -c requirements.txt - # importlib-metadata diff --git a/settings/common.py b/settings/common.py index bb873197..8e5b4920 100644 --- a/settings/common.py +++ b/settings/common.py @@ -8,6 +8,8 @@ import os import os.path import sys +from datetime import timedelta + BASE_DIR = os.path.dirname(os.path.dirname(__file__)) @@ -319,6 +321,7 @@ INSTALLED_APPS = [ "taiga.front", "taiga.users", "taiga.userstorage", + "taiga.auth.token_denylist", "taiga.external_apps", "taiga.projects", "taiga.projects.references", @@ -439,6 +442,15 @@ LOGGING = { AUTH_USER_MODEL = "users.User" + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=3), + 'CANCEL_TOKEN_LIFETIME': timedelta(days=100), +} + +FLUSH_REFRESHED_TOKENS_PERIODICITY = 3 * 24 * 3600 # seconds + FORMAT_MODULE_PATH = "taiga.base.formats" DATE_INPUT_FORMATS = ( @@ -452,13 +464,10 @@ AUTHENTICATION_BACKENDS = ( "django.contrib.auth.backends.ModelBackend", # default ) -MAX_AGE_AUTH_TOKEN = None -MAX_AGE_CANCEL_ACCOUNT = 30 * 24 * 60 * 60 # 30 days in seconds - REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( # Mainly used by taiga-front - "taiga.auth.backends.Token", + 'taiga.auth.authentication.JWTAuthentication', # Mainly used for api debug. "taiga.auth.backends.Session", diff --git a/taiga/auth/__init__.py b/taiga/auth/__init__.py index b92267f0..8293af3b 100644 --- a/taiga/auth/__init__.py +++ b/taiga/auth/__init__.py @@ -4,4 +4,27 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. # # Copyright (c) 2021-present Kaleidos Ventures SL - +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. diff --git a/taiga/auth/api.py b/taiga/auth/api.py index 944e4665..9863fece 100644 --- a/taiga/auth/api.py +++ b/taiga/auth/api.py @@ -10,51 +10,99 @@ from functools import partial from django.utils.translation import ugettext as _ from django.conf import settings -from taiga.base.api import viewsets -from taiga.base.decorators import list_route from taiga.base import exceptions as exc from taiga.base import response +from taiga.base.api import viewsets +from taiga.base.decorators import list_route +from taiga.projects.services.invitations import accept_invitation_by_existing_user -from .validators import PublicRegisterValidator -from .validators import PrivateRegisterValidator - +from . import serializers +from .authentication import AUTH_HEADER_TYPES +from .permissions import AuthPermission from .services import private_register_for_new_user from .services import public_register from .services import make_auth_response_data from .services import get_auth_plugins -from .services import accept_invitation_by_existing_user - -from .permissions import AuthPermission from .throttling import LoginFailRateThrottle, RegisterSuccessRateThrottle -def _parse_data(data:dict, *, cls): +def _validate_data(data:dict, *, cls): """ - Generic function for parse user data using - specified validator on `cls` keyword parameter. + Generic function for parse and validate user + data using specified validator on `cls` + keyword parameter. Raises: RequestValidationError exception if some errors found when data is validated. - - Returns the parsed data. """ validator = cls(data=data) if not validator.is_valid(): raise exc.RequestValidationError(validator.errors) - return validator.data + return validator.object -# Parse public register data -parse_public_register_data = partial(_parse_data, cls=PublicRegisterValidator) -# Parse private register data for new user -parse_private_register_data = partial(_parse_data, cls=PrivateRegisterValidator) +get_token = partial(_validate_data, cls=serializers.TokenObtainPairSerializer) +refresh_token = partial(_validate_data, cls=serializers.TokenRefreshSerializer) +verify_token = partial(_validate_data, cls=serializers.TokenVerifySerializer) +parse_public_register_data = partial(_validate_data, cls=serializers.PublicRegisterSerializer) +parse_private_register_data = partial(_validate_data, cls=serializers.PrivateRegisterSerializer) class AuthViewSet(viewsets.ViewSet): permission_classes = (AuthPermission,) throttle_classes = (LoginFailRateThrottle, RegisterSuccessRateThrottle) + serializer_class = None + + www_authenticate_realm = 'api' + + def get_authenticate_header(self, request): + return '{0} realm="{1}"'.format( + AUTH_HEADER_TYPES[0], + self.www_authenticate_realm, + ) + + # Login view: /api/v1/auth + def create(self, request, **kwargs): + self.check_permissions(request, 'get_token', None) + auth_plugins = get_auth_plugins() + + login_type = request.DATA.get("type", "").lower() + + if login_type == "normal": + # Default login process + data = get_token(request.DATA) + elif login_type in auth_plugins: + data = auth_plugins[login_type]['login_func'](request) + else: + raise exc.BadRequest(_("invalid login type")) + + # Processing invitation token + invitation_token = request.DATA.get("invitation_token", None) + if invitation_token: + accept_invitation_by_existing_user(invitation_token, data['id']) + + return response.Ok(data) + + # Refresh token view: /api/v1/auth/refresh + @list_route(methods=["POST"]) + def refresh(self, request, **kwargs): + self.check_permissions(request, 'refresh_token', None) + data = refresh_token(request.DATA) + return response.Ok(data) + + # Validate token view: /api/v1/auth/verify + @list_route(methods=["POST"]) + def verify(self, request, **kwargs): + if not settings.DEBUG: + return response.Forbidden() + + self.check_permissions(request, 'verify_token', None) + data = verify_token(request.DATA) + return response.Ok(data) + + def _public_register(self, request): if not settings.PUBLIC_REGISTER_ENABLED: raise exc.BadRequest(_("Public registration is disabled.")) @@ -75,6 +123,7 @@ class AuthViewSet(viewsets.ViewSet): data = make_auth_response_data(user) return response.Created(data) + # Register user: /api/v1/auth/register @list_route(methods=["POST"]) def register(self, request, **kwargs): accepted_terms = request.DATA.get("accepted_terms", None) @@ -90,18 +139,3 @@ class AuthViewSet(viewsets.ViewSet): return self._private_register(request) raise exc.BadRequest(_("invalid registration type")) - # Login view: /api/v1/auth - def create(self, request, **kwargs): - self.check_permissions(request, 'create', None) - auth_plugins = get_auth_plugins() - - login_type = request.DATA.get("type", None) - invitation_token = request.DATA.get("invitation_token", None) - - if login_type in auth_plugins: - data = auth_plugins[login_type]['login_func'](request) - if invitation_token: - accept_invitation_by_existing_user(invitation_token, data['id']) - return response.Ok(data) - - raise exc.BadRequest(_("invalid login type")) diff --git a/taiga/auth/authentication.py b/taiga/auth/authentication.py new file mode 100644 index 00000000..6f4fd7ec --- /dev/null +++ b/taiga/auth/authentication.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ +from taiga.base.api import HTTP_HEADER_ENCODING, authentication + +from .exceptions import AuthenticationFailed, InvalidToken, TokenError +from .settings import api_settings + +AUTH_HEADER_TYPES = api_settings.AUTH_HEADER_TYPES + +if not isinstance(api_settings.AUTH_HEADER_TYPES, (list, tuple)): + AUTH_HEADER_TYPES = (AUTH_HEADER_TYPES,) + +AUTH_HEADER_TYPE_BYTES = set( + h.encode(HTTP_HEADER_ENCODING) + for h in AUTH_HEADER_TYPES +) + + +class JWTAuthentication(authentication.BaseAuthentication): + """ + An authentication plugin that authenticates requests through a JSON web + token provided in a request header. + """ + www_authenticate_realm = 'api' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user_model = get_user_model() + + def authenticate(self, request): + header = self.get_header(request) + if header is None: + return None + + raw_token = self.get_raw_token(header) + if raw_token is None: + return None + + validated_token = self.get_validated_token(raw_token) + + return self.get_user(validated_token), validated_token + + def authenticate_header(self, request): + return '{0} realm="{1}"'.format( + AUTH_HEADER_TYPES[0], + self.www_authenticate_realm, + ) + + def get_header(self, request): + """ + Extracts the header containing the JSON web token from the given + request. + """ + header = request.META.get(api_settings.AUTH_HEADER_NAME) + + if isinstance(header, str): + # Work around django test client oddness + header = header.encode(HTTP_HEADER_ENCODING) + + return header + + def get_raw_token(self, header): + """ + Extracts an unvalidated JSON web token from the given "Authorization" + header value. + """ + parts = header.split() + + if len(parts) == 0: + # Empty AUTHORIZATION header sent + return None + + if parts[0] not in AUTH_HEADER_TYPE_BYTES: + # Assume the header does not contain a JSON web token + return None + + if len(parts) != 2: + raise AuthenticationFailed( + _('Authorization header must contain two space-delimited values'), + code='bad_authorization_header', + ) + + return parts[1] + + def get_validated_token(self, raw_token): + """ + Validates an encoded JSON web token and returns a validated token + wrapper object. + """ + messages = [] + for AuthToken in api_settings.AUTH_TOKEN_CLASSES: + try: + return AuthToken(raw_token) + except TokenError as e: + messages.append({'token_class': AuthToken.__name__, + 'token_type': AuthToken.token_type, + 'message': e.args[0]}) + + raise InvalidToken({ + 'detail': _('Given token not valid for any token type'), + 'messages': messages, + }) + + def get_user(self, validated_token): + """ + Attempts to find and return a user using the given validated token. + """ + try: + user_id = validated_token[api_settings.USER_ID_CLAIM] + except KeyError: + raise InvalidToken(_('Token contained no recognizable user identification')) + + try: + user = self.user_model.objects.get(**{api_settings.USER_ID_FIELD: user_id}) + except self.user_model.DoesNotExist: + raise AuthenticationFailed(_('User not found'), code='user_not_found') + + if not user.is_active or user.is_system: + raise AuthenticationFailed(_('User is inactive'), code='user_inactive') + + return user + + +class JWTTokenUserAuthentication(JWTAuthentication): + def get_user(self, validated_token): + """ + Returns a stateless user object which is backed by the given validated + token. + """ + if api_settings.USER_ID_CLAIM not in validated_token: + # The TokenUser class assumes tokens will have a recognizable user + # identifier claim. + raise InvalidToken(_('Token contained no recognizable user identification')) + + return api_settings.TOKEN_USER_CLASS(validated_token) diff --git a/taiga/auth/backends.py b/taiga/auth/backends.py index 7bebe400..83cafe96 100644 --- a/taiga/auth/backends.py +++ b/taiga/auth/backends.py @@ -4,33 +4,39 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. # # Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. -""" -Authentication backends for rest framework. +import jwt +from django.utils.translation import gettext_lazy as _ +from jwt import InvalidAlgorithmError, InvalidTokenError, algorithms -This module exposes two backends: session and token. - -The first (session) is a modified version of standard -session authentication backend of restframework with -csrf token disabled. - -And the second (token) implements own version of oauth2 -like authentication but with selfcontained tokens. Thats -makes authentication totally stateless. - -It uses django signing framework for create new -self-contained tokens. This trust tokes from external -fraudulent modifications. -""" - -import re - -from django.conf import settings -from django.utils import timezone -from datetime import timedelta from taiga.base.api.authentication import BaseAuthentication -from .tokens import get_user_for_token +from .exceptions import TokenBackendError +from .utils import format_lazy class Session(BaseAuthentication): @@ -39,7 +45,6 @@ class Session(BaseAuthentication): `taiga.base.api.authentication.SessionAuthentication` but with csrf disabled (for obvious reasons because it is for api. - NOTE: this is only for api web interface. Is not used for common api usage and should be disabled on production. """ @@ -54,34 +59,72 @@ class Session(BaseAuthentication): return (user, None) -class Token(BaseAuthentication): - """ - Self-contained stateless authentication implementation - that works similar to oauth2. - It uses django signing framework for trust data stored - in the token. - """ +ALLOWED_ALGORITHMS = ( + 'HS256', + 'HS384', + 'HS512', + 'RS256', + 'RS384', + 'RS512', +) - auth_rx = re.compile(r"^Bearer (.+)$") - def authenticate(self, request): - if "HTTP_AUTHORIZATION" not in request.META: - return None +class TokenBackend: + def __init__(self, algorithm, signing_key=None, verifying_key=None, audience=None, issuer=None): + self._validate_algorithm(algorithm) - token_rx_match = self.auth_rx.search(request.META["HTTP_AUTHORIZATION"]) - if not token_rx_match: - return None + self.algorithm = algorithm + self.signing_key = signing_key + self.audience = audience + self.issuer = issuer + if algorithm.startswith('HS'): + self.verifying_key = signing_key + else: + self.verifying_key = verifying_key - token = token_rx_match.group(1) - max_age_auth_token = getattr(settings, "MAX_AGE_AUTH_TOKEN", None) - user = get_user_for_token(token, "authentication", - max_age=max_age_auth_token) + def _validate_algorithm(self, algorithm): + """ + Ensure that the nominated algorithm is recognized, and that cryptography is installed for those + algorithms that require it + """ + if algorithm not in ALLOWED_ALGORITHMS: + raise TokenBackendError(format_lazy(_("Unrecognized algorithm type '{}'"), algorithm)) - if user.last_login is None or user.last_login < (timezone.now() - timedelta(minutes=1)): - user.last_login = timezone.now() - user.save(update_fields=["last_login"]) + if algorithm in algorithms.requires_cryptography and not algorithms.has_crypto: + raise TokenBackendError(format_lazy(_("You must have cryptography installed to use {}."), algorithm)) - return (user, token) + def encode(self, payload): + """ + Returns an encoded token for the given payload dictionary. + """ + jwt_payload = payload.copy() + if self.audience is not None: + jwt_payload['aud'] = self.audience + if self.issuer is not None: + jwt_payload['iss'] = self.issuer - def authenticate_header(self, request): - return 'Bearer realm="api"' + token = jwt.encode(jwt_payload, self.signing_key, algorithm=self.algorithm) + if isinstance(token, bytes): + # For PyJWT <= 1.7.1 + return token.decode('utf-8') + # For PyJWT >= 2.0.0a1 + return token + + def decode(self, token, verify=True): + """ + Performs a validation of the given token and returns its payload + dictionary. + + Raises a `TokenBackendError` if the token is malformed, if its + signature check fails, or if its 'exp' claim indicates it has expired. + """ + try: + return jwt.decode( + token, self.verifying_key, algorithms=[self.algorithm], verify=verify, + audience=self.audience, issuer=self.issuer, + options={'verify_aud': self.audience is not None, "verify_signature": verify} + ) + except InvalidAlgorithmError as ex: + raise TokenBackendError(_('Invalid algorithm specified')) from ex + except InvalidTokenError: + raise TokenBackendError(_('Token is invalid or expired')) diff --git a/taiga/auth/compat.py b/taiga/auth/compat.py new file mode 100644 index 00000000..36a2805e --- /dev/null +++ b/taiga/auth/compat.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import warnings + +try: + from django.urls import reverse, reverse_lazy +except ImportError: + from django.core.urlresolvers import reverse, reverse_lazy # NOQA + + +class RemovedInDjango20Warning(DeprecationWarning): + pass + + +class CallableBool: # pragma: no cover + """ + An boolean-like object that is also callable for backwards compatibility. + """ + do_not_call_in_templates = True + + def __init__(self, value): + self.value = value + + def __bool__(self): + return self.value + + def __call__(self): + warnings.warn( + "Using user.is_authenticated() and user.is_anonymous() as a method " + "is deprecated. Remove the parentheses to use it as an attribute.", + RemovedInDjango20Warning, stacklevel=2 + ) + return self.value + + def __nonzero__(self): # Python 2 compatibility + return self.value + + def __repr__(self): + return 'CallableBool(%r)' % self.value + + def __eq__(self, other): + return self.value == other + + def __ne__(self, other): + return self.value != other + + def __or__(self, other): + return bool(self.value or other) + + def __hash__(self): + return hash(self.value) + + +CallableFalse = CallableBool(False) +CallableTrue = CallableBool(True) diff --git a/taiga/auth/exceptions.py b/taiga/auth/exceptions.py new file mode 100644 index 00000000..2f2c0f44 --- /dev/null +++ b/taiga/auth/exceptions.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from django.utils.translation import gettext_lazy as _ +from taiga.base import exceptions, status + + +class TokenError(Exception): + pass + + +class TokenBackendError(Exception): + pass + + +class DetailDictMixin: + def __init__(self, detail=None, code=None): + """ + Builds a detail dictionary for the error to give more information to API + users. + """ + detail_dict = {'detail': self.default_detail, 'code': self.default_code} + + if isinstance(detail, dict): + detail_dict.update(detail) + elif detail is not None: + detail_dict['detail'] = detail + + if code is not None: + detail_dict['code'] = code + + super().__init__(detail_dict) + + +class AuthenticationFailed(DetailDictMixin, exceptions.AuthenticationFailed): + default_code = 'authentication_failed' + + +class InvalidToken(AuthenticationFailed): + status_code = status.HTTP_401_UNAUTHORIZED + default_detail = _('Token is invalid or expired') + default_code = 'token_not_valid' diff --git a/taiga/auth/models.py b/taiga/auth/models.py new file mode 100644 index 00000000..7fd50336 --- /dev/null +++ b/taiga/auth/models.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from django.contrib.auth import models as auth_models +from django.db.models.manager import EmptyManager +from django.utils.functional import cached_property + +from .compat import CallableFalse, CallableTrue +from .settings import api_settings + + +class TokenUser: + """ + A dummy user class modeled after django.contrib.auth.models.AnonymousUser. + Used in conjunction with the `JWTTokenUserAuthentication` backend to + implement single sign-on functionality across services which share the same + secret key. `JWTTokenUserAuthentication` will return an instance of this + class instead of a `User` model instance. Instances of this class act as + stateless user objects which are backed by validated tokens. + """ + # User is always active since Simple JWT will never issue a token for an + # inactive user + is_active = True + + _groups = EmptyManager(auth_models.Group) + _user_permissions = EmptyManager(auth_models.Permission) + + def __init__(self, token): + self.token = token + + def __str__(self): + return 'TokenUser {}'.format(self.id) + + @cached_property + def id(self): + return self.token[api_settings.USER_ID_CLAIM] + + @cached_property + def pk(self): + return self.id + + @cached_property + def username(self): + return self.token.get('username', '') + + @cached_property + def is_staff(self): + return self.token.get('is_staff', False) + + @cached_property + def is_superuser(self): + return self.token.get('is_superuser', False) + + def __eq__(self, other): + return self.id == other.id + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash(self.id) + + def save(self): + raise NotImplementedError('Token users have no DB representation') + + def delete(self): + raise NotImplementedError('Token users have no DB representation') + + def set_password(self, raw_password): + raise NotImplementedError('Token users have no DB representation') + + def check_password(self, raw_password): + raise NotImplementedError('Token users have no DB representation') + + @property + def groups(self): + return self._groups + + @property + def user_permissions(self): + return self._user_permissions + + def get_group_permissions(self, obj=None): + return set() + + def get_all_permissions(self, obj=None): + return set() + + def has_perm(self, perm, obj=None): + return False + + def has_perms(self, perm_list, obj=None): + return False + + def has_module_perms(self, module): + return False + + @property + def is_anonymous(self): + return CallableFalse + + @property + def is_authenticated(self): + return CallableTrue + + def get_username(self): + return self.username diff --git a/taiga/auth/permissions.py b/taiga/auth/permissions.py index 68bcbd49..f42da679 100644 --- a/taiga/auth/permissions.py +++ b/taiga/auth/permissions.py @@ -9,5 +9,7 @@ from taiga.base.api.permissions import TaigaResourcePermission, AllowAny class AuthPermission(TaigaResourcePermission): - create_perms = AllowAny() + get_token_perms = AllowAny() + refresh_token_perms = AllowAny() + verify_token_perms = AllowAny() register_perms = AllowAny() diff --git a/taiga/auth/serializers.py b/taiga/auth/serializers.py new file mode 100644 index 00000000..1b92e24b --- /dev/null +++ b/taiga/auth/serializers.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL + +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import bleach +import re + +from django.core import validators as core_validators +from django.utils.translation import ugettext as _ + +from taiga.base.api import serializers +from taiga.base.exceptions import ValidationError + +from .services import login, refresh_token, verify_token + + +class TokenObtainPairSerializer(serializers.Serializer): + username = serializers.CharField() + password = serializers.CharField(write_only=True) + + def validate(self, attrs): + authenticate_kwargs = { + 'username': attrs['username'], + 'password': attrs['password'], + } + + return login(**authenticate_kwargs) + + +class TokenRefreshSerializer(serializers.Serializer): + refresh = serializers.CharField() + + def validate(self, attrs): + return refresh_token(attrs['refresh']) + + +class TokenVerifySerializer(serializers.Serializer): + token = serializers.CharField() + + def validate(self, attrs): + return verify_token(attrs['token']) + + + +class BaseRegisterSerializer(serializers.Serializer): + full_name = serializers.CharField(max_length=36) + email = serializers.EmailField(max_length=255) + username = serializers.CharField(max_length=255) + password = serializers.CharField(min_length=6) + + def validate_username(self, attrs, source): + value = attrs[source] + validator = core_validators.RegexValidator(re.compile(r'^[\w.-]+$'), _("invalid username"), "invalid") + + try: + validator(value) + except ValidationError: + raise ValidationError(_("Required. 255 characters or fewer. Letters, numbers " + "and /./-/_ characters'")) + return attrs + + def validate_full_name(self, attrs, source): + value = attrs[source] + if value != bleach.clean(value): + raise ValidationError(_("Invalid full name")) + + if re.search(r"http[s]?:", value): + raise ValidationError(_("Invalid full name")) + + return attrs + + +class PublicRegisterSerializer(BaseRegisterSerializer): + pass + + +class PrivateRegisterSerializer(BaseRegisterSerializer): + token = serializers.CharField(max_length=255, required=True) diff --git a/taiga/auth/services.py b/taiga/auth/services.py index 9f8a352b..6952638f 100644 --- a/taiga/auth/services.py +++ b/taiga/auth/services.py @@ -4,35 +4,39 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. # # Copyright (c) 2021-present Kaleidos Ventures SL +# -""" -This module contains a domain logic for authentication -process. It called services because in DDD says it. - -NOTE: Python doesn't have java limitations for "everything -should be contained in a class". Because of that, it -not uses clasess and uses simple functions. -""" +from typing import Callable import uuid -from django.apps import apps +from django.conf import settings from django.contrib.auth import get_user_model -from django.db import transaction as tx +from django.contrib.auth.models import update_last_login from django.db import IntegrityError -from django.utils.translation import ugettext as _ +from django.db import transaction as tx +from django.utils.translation import gettext_lazy as _ from taiga.base import exceptions as exc from taiga.base.mails import mail_builder +from taiga.users.models import User from taiga.users.serializers import UserAdminSerializer from taiga.users.services import get_and_validate_user +from taiga.projects.services.invitations import get_membership_by_token -from .tokens import get_token_for_user +from .exceptions import AuthenticationFailed, InvalidToken, TokenError +from .settings import api_settings +from .tokens import RefreshToken, CancelToken, UntypedToken from .signals import user_registered as user_registered_signal + +##################### +## AUTH PLUGINS +##################### + auth_plugins = {} -def register_auth_plugin(name, login_func): +def register_auth_plugin(name: str, login_func: Callable): auth_plugins[name] = { "login_func": login_func, } @@ -42,13 +46,82 @@ def get_auth_plugins(): return auth_plugins +##################### +## AUTH SERVICES +##################### + +def make_auth_response_data(user): + serializer = UserAdminSerializer(user) + data = dict(serializer.data) + + refresh = RefreshToken.for_user(user) + + data['refresh'] = str(refresh) + data['auth_token'] = str(refresh.access_token) + + return data + + +def login(username: str, password: str): + try: + user = get_and_validate_user(username=username, password=password) + except exc.WrongArguments: + raise AuthenticationFailed( + _('No active account found with the given credentials'), + 'invalid_credentials', + ) + + # Generate data + data = make_auth_response_data(user) + + if api_settings.UPDATE_LAST_LOGIN: + update_last_login(None, user) + + return data + + +def refresh_token(refresh_token: str): + try: + refresh = RefreshToken(refresh_token) + except TokenError: + raise InvalidToken() + + data = {'auth_token': str(refresh.access_token)} + + if api_settings.ROTATE_REFRESH_TOKENS: + if api_settings.DENYLIST_AFTER_ROTATION: + try: + # Attempt to denylist the given refresh token + refresh.denylist() + except AttributeError: + # If denylist app not installed, `denylist` method will + # not be present + pass + + refresh.set_jti() + refresh.set_exp() + + data['refresh'] = str(refresh) + + return data + + +def verify_token(token: str): + UntypedToken(token) + return {} + + +##################### +## REGISTER SERVICES +##################### + def send_register_email(user) -> bool: """ Given a user, send register welcome email message to specified user. """ - cancel_token = get_token_for_user(user, "cancel_account") - context = {"user": user, "cancel_token": cancel_token} + cancel_token = CancelToken.for_user(user) + context = {"user": user, "cancel_token": str(cancel_token)} email = mail_builder.registered_user(user, context) return bool(email.send()) @@ -60,32 +133,16 @@ def is_user_already_registered(*, username:str, email:str) -> (bool, str): Returns a tuple containing a boolean value that indicates if the user exists and in case he does whats the duplicated attribute """ - user_model = get_user_model() - if user_model.objects.filter(username__iexact=username): + if user_model.objects.filter(username__iexact=username).exists(): return (True, _("Username is already in use.")) - if user_model.objects.filter(email__iexact=email): + if user_model.objects.filter(email__iexact=email).exists(): return (True, _("Email is already in use.")) return (False, None) -def get_membership_by_token(token:str): - """ - Given a token, returns a membership instance - that matches with specified token. - - If not matches with any membership NotFound exception - is raised. - """ - membership_model = apps.get_model("projects", "Membership") - qs = membership_model.objects.filter(token=token) - if len(qs) == 0: - raise exc.NotFound(_("Token does not match any valid invitation.")) - return qs[0] - - @tx.atomic def public_register(username:str, password:str, email:str, full_name:str): """ @@ -121,20 +178,6 @@ def public_register(username:str, password:str, email:str, full_name:str): return user -@tx.atomic -def accept_invitation_by_existing_user(token:str, user_id:int): - user_model = get_user_model() - user = user_model.objects.get(id=user_id) - membership = get_membership_by_token(token) - - try: - membership.user = user - membership.save(update_fields=["user"]) - except IntegrityError: - raise exc.IntegrityError(_("This user is already a member of the project.")) - return user - - @tx.atomic def private_register_for_new_user(token:str, username:str, email:str, full_name:str, password:str): @@ -168,30 +211,3 @@ def private_register_for_new_user(token:str, username:str, email:str, user_registered_signal.send(sender=user.__class__, user=user) return user - - -def make_auth_response_data(user) -> dict: - """ - Given a domain and user, creates data structure - using python dict containing a representation - of the logged user. - """ - serializer = UserAdminSerializer(user) - data = dict(serializer.data) - data["auth_token"] = get_token_for_user(user, "authentication") - return data - - -def normal_login_func(request): - username = request.DATA.get('username', None) - password = request.DATA.get('password', None) - - username = str(username) if username else None - password = str(password) if password else None - - user = get_and_validate_user(username=username, password=password) - data = make_auth_response_data(user) - return data - - -register_auth_plugin("normal", normal_login_func) diff --git a/taiga/auth/settings.py b/taiga/auth/settings.py new file mode 100644 index 00000000..311285e5 --- /dev/null +++ b/taiga/auth/settings.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Settings +======== + +Some of Simple JWT's behavior can be customized through settings variables in +``settings`` module: + +.. code-block:: python + + # Django project settings.py + + from datetime import timedelta + + ... + + SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), + 'CANCEL_TOKEN_LIFETIME': timedelta(days=30), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': False, + 'DENYLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': False, + + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': settings.SECRET_KEY, + 'VERIFYING_KEY': None, + 'AUDIENCE': None, + 'ISSUER': None, + + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + 'USER_AUTHENTICATION_RULE': 'taiga.auth.authentication.default_user_authentication_rule', + + 'AUTH_TOKEN_CLASSES': ('taiga.auth.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', + + 'JTI_CLAIM': 'jti', + } + +Above, the default values for these settings are shown. + +``ACCESS_TOKEN_LIFETIME`` +------------------------- + +A ``datetime.timedelta`` object which specifies how long access tokens are +valid. This ``timedelta`` value is added to the current UTC time during token +generation to obtain the token's default "exp" claim value. + +``REFRESH_TOKEN_LIFETIME`` +-------------------------- + +A ``datetime.timedelta`` object which specifies how long refresh tokens are +valid. This ``timedelta`` value is added to the current UTC time during token +generation to obtain the token's default "exp" claim value. + +``CANCEL_TOKEN_LIFETIME`` +-------------------------- + +A ``datetime.timedelta`` object which specifies how long cancel tokens are +valid. This ``timedelta`` value is added to the current UTC time during token +generation to obtain the token's default "exp" claim value. + +``ROTATE_REFRESH_TOKENS`` +------------------------- + +When set to ``True``, if a refresh token is submitted to the +``TokenRefreshView``, a new refresh token will be returned along with the new +access token. This new refresh token will be supplied via a "refresh" key in +the JSON response. New refresh tokens will have a renewed expiration time +which is determined by adding the timedelta in the ``REFRESH_TOKEN_LIFETIME`` +setting to the current time when the request is made. If the denylist app is +in use and the ``DENYLIST_AFTER_ROTATION`` setting is set to ``True``, refresh +tokens submitted to the refresh view will be added to the denylist. + +``DENYLIST_AFTER_ROTATION`` +---------------------------- + +When set to ``True``, causes refresh tokens submitted to the +``TokenRefreshView`` to be added to the denylist if the +``ROTATE_REFRESH_TOKENS`` setting is set to ``True``. + +``UPDATE_LAST_LOGIN`` +---------------------------- + +When set to ``True``, last_login field in the auth_user table is updated upon +login (TokenObtainPairView). + + Warning: Updating last_login will dramatically increase the number of database + transactions. People abusing the views could slow the server and this could be + a security vulnerability. If you really want this, throttle the endpoint with + DRF at the very least. + +``ALGORITHM`` +------------- + +The algorithm from the PyJWT library which will be used to perform +signing/verification operations on tokens. To use symmetric HMAC signing and +verification, the following algorithms may be used: ``'HS256'``, ``'HS384'``, +``'HS512'``. When an HMAC algorithm is chosen, the ``SIGNING_KEY`` setting +will be used as both the signing key and the verifying key. In that case, the +``VERIFYING_KEY`` setting will be ignored. To use asymmetric RSA signing and +verification, the following algorithms may be used: ``'RS256'``, ``'RS384'``, +``'RS512'``. When an RSA algorithm is chosen, the ``SIGNING_KEY`` setting must +be set to a string that contains an RSA private key. Likewise, the +``VERIFYING_KEY`` setting must be set to a string that contains an RSA public +key. + +``SIGNING_KEY`` +--------------- + +The signing key that is used to sign the content of generated tokens. For HMAC +signing, this should be a random string with at least as many bits of data as +is required by the signing protocol. For RSA signing, this should be a string +that contains an RSA private key that is 2048 bits or longer. Since Simple JWT +defaults to using 256-bit HMAC signing, the ``SIGNING_KEY`` setting defaults to +the value of the ``SECRET_KEY`` setting for your django project. Although this +is the most reasonable default that Simple JWT can provide, it is recommended +that developers change this setting to a value that is independent from the +django project secret key. This will make changing the signing key used for +tokens easier in the event that it is compromised. + +``VERIFYING_KEY`` +----------------- + +The verifying key which is used to verify the content of generated tokens. If +an HMAC algorithm has been specified by the ``ALGORITHM`` setting, the +``VERIFYING_KEY`` setting will be ignored and the value of the ``SIGNING_KEY`` +setting will be used. If an RSA algorithm has been specified by the +``ALGORITHM`` setting, the ``VERIFYING_KEY`` setting must be set to a string +that contains an RSA public key. + +``AUDIENCE`` +------------- + +The audience claim to be included in generated tokens and/or validated in +decoded tokens. When set to ``None``, this field is excluded from tokens and is +not validated. + +``ISSUER`` +---------- + +The issuer claim to be included in generated tokens and/or validated in decoded +tokens. When set to ``None``, this field is excluded from tokens and is not +validated. + +``AUTH_HEADER_TYPES`` +--------------------- + +The authorization header type(s) that will be accepted for views that require +authentication. For example, a value of ``'Bearer'`` means that views +requiring authentication would look for a header with the following format: +``Authorization: Bearer ``. This setting may also contain a list or +tuple of possible header types (e.g. ``('Bearer', 'JWT')``). If a list or +tuple is used in this way, and authentication fails, the first item in the +collection will be used to build the "WWW-Authenticate" header in the response. + +``AUTH_HEADER_NAME`` +---------------------------- + +The authorization header name to be used for authentication. +The default is ``HTTP_AUTHORIZATION`` which will accept the +``Authorization`` header in the request. For example if you'd +like to use ``X_Access_Token`` in the header of your requests +please specify the ``AUTH_HEADER_NAME`` to be +``HTTP_X_ACCESS_TOKEN`` in your settings. + +``USER_ID_FIELD`` +----------------- + +The database field from the user model that will be included in generated +tokens to identify users. It is recommended that the value of this setting +specifies a field that does not normally change once its initial value is +chosen. For example, specifying a "username" or "email" field would be a poor +choice since an account's username or email might change depending on how +account management in a given service is designed. This could allow a new +account to be created with an old username while an existing token is still +valid which uses that username as a user identifier. + +``USER_ID_CLAIM`` +----------------- + +The claim in generated tokens which will be used to store user identifiers. +For example, a setting value of ``'user_id'`` would mean generated tokens +include a "user_id" claim that contains the user's identifier. + +``USER_AUTHENTICATION_RULE`` +---------------------------- + +Callable to determine if the user is permitted to authenticate. This rule +is applied after a valid token is processed. The user object is passed +to the callable as an argument. The default rule is to check that the ``is_active`` +flag is still ``True``. The callable must return a boolean, ``True`` if authorized, +``False`` otherwise resulting in a 401 status code. + +``AUTH_TOKEN_CLASSES`` +---------------------- + +A list of dot paths to classes that specify the types of token that are allowed +to prove authentication. More about this in the "Token types" section below. + +``TOKEN_TYPE_CLAIM`` +-------------------- + +The claim name that is used to store a token's type. More about this in the +"Token types" section below. + +``JTI_CLAIM`` +------------- + +The claim name that is used to store a token's unique identifier. This +identifier is used to identify revoked tokens in the denylist app. It may be +necessary in some cases to use another claim besides the default "jti" claim to +store such a value. +""" + + +from datetime import timedelta + +from django.conf import settings +from django.test.signals import setting_changed +from django.utils.translation import gettext_lazy as _ +from taiga.base.api.settings import APISettings + +from .utils import format_lazy + +USER_SETTINGS = getattr(settings, 'SIMPLE_JWT', None) + +DEFAULTS = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=3), + 'CANCEL_TOKEN_LIFETIME': timedelta(days=100), + 'ROTATE_REFRESH_TOKENS': True, + 'DENYLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': True, + + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': settings.SECRET_KEY, + 'VERIFYING_KEY': None, + 'AUDIENCE': None, + 'ISSUER': None, + + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + + 'AUTH_TOKEN_CLASSES': ('taiga.auth.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', + + 'JTI_CLAIM': 'jti', + 'TOKEN_USER_CLASS': 'taiga.auth.models.TokenUser', +} + +IMPORT_STRINGS = ( + 'AUTH_TOKEN_CLASSES', + 'TOKEN_USER_CLASS', +) + +api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) + + +def reload_api_settings(*args, **kwargs): # pragma: no cover + global api_settings + + setting, value = kwargs['setting'], kwargs['value'] + + if setting == 'SIMPLE_JWT': + api_settings = APISettings(value, DEFAULTS, IMPORT_STRINGS) + + +setting_changed.connect(reload_api_settings) diff --git a/taiga/auth/state.py b/taiga/auth/state.py new file mode 100644 index 00000000..9ffe52cb --- /dev/null +++ b/taiga/auth/state.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from .backends import TokenBackend +from .settings import api_settings + +token_backend = TokenBackend(api_settings.ALGORITHM, api_settings.SIGNING_KEY, + api_settings.VERIFYING_KEY, api_settings.AUDIENCE, api_settings.ISSUER) diff --git a/taiga/auth/throttling.py b/taiga/auth/throttling.py index b393fe36..233c9420 100644 --- a/taiga/auth/throttling.py +++ b/taiga/auth/throttling.py @@ -10,13 +10,13 @@ from taiga.base import throttling class LoginFailRateThrottle(throttling.GlobalThrottlingMixin, throttling.ThrottleByActionMixin, throttling.SimpleRateThrottle): scope = "login-fail" - throttled_actions = ["create"] + throttled_actions = ["create", "refresh", "validate"] def throttle_success(self, request, view): return True def finalize(self, request, response, view): - if response.status_code == 400: + if response.status_code in [400, 401]: self.history.insert(0, self.now) self.cache.set(self.key, self.history, self.duration) diff --git a/taiga/auth/token_denylist/__init__.py b/taiga/auth/token_denylist/__init__.py new file mode 100644 index 00000000..7d3ac10b --- /dev/null +++ b/taiga/auth/token_denylist/__init__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +default_app_config = 'taiga.auth.token_denylist.apps.TokenDenylistConfig' diff --git a/taiga/auth/token_denylist/admin.py b/taiga/auth/token_denylist/admin.py new file mode 100644 index 00000000..a0f7b30a --- /dev/null +++ b/taiga/auth/token_denylist/admin.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import DenylistedToken, OutstandingToken + + +class OutstandingTokenAdmin(admin.ModelAdmin): + list_display = ( + 'jti', + 'user', + 'created_at', + 'expires_at', + ) + search_fields = ( + 'user__id', + 'jti', + ) + ordering = ( + 'user', + ) + + def get_queryset(self, *args, **kwargs): + qs = super().get_queryset(*args, **kwargs) + + return qs.select_related('user') + + # Read-only behavior defined below + actions = None + + def get_readonly_fields(self, *args, **kwargs): + return [f.name for f in self.model._meta.fields] + + def has_add_permission(self, *args, **kwargs): + return False + + def has_delete_permission(self, *args, **kwargs): + return False + + def has_change_permission(self, request, obj=None): + return ( + request.method in ['GET', 'HEAD'] and # noqa: W504 + super().has_change_permission(request, obj) + ) + + +admin.site.register(OutstandingToken, OutstandingTokenAdmin) + + +class DenylistedTokenAdmin(admin.ModelAdmin): + list_display = ( + 'token_jti', + 'token_user', + 'token_created_at', + 'token_expires_at', + 'denylisted_at', + ) + search_fields = ( + 'token__user__id', + 'token__jti', + ) + ordering = ( + 'token__user', + ) + + def get_queryset(self, *args, **kwargs): + qs = super().get_queryset(*args, **kwargs) + + return qs.select_related('token__user') + + def token_jti(self, obj): + return obj.token.jti + token_jti.short_description = _('jti') + token_jti.admin_order_field = 'token__jti' + + def token_user(self, obj): + return obj.token.user + token_user.short_description = _('user') + token_user.admin_order_field = 'token__user' + + def token_created_at(self, obj): + return obj.token.created_at + token_created_at.short_description = _('created at') + token_created_at.admin_order_field = 'token__created_at' + + def token_expires_at(self, obj): + return obj.token.expires_at + token_expires_at.short_description = _('expires at') + token_expires_at.admin_order_field = 'token__expires_at' + + +admin.site.register(DenylistedToken, DenylistedTokenAdmin) diff --git a/taiga/auth/token_denylist/apps.py b/taiga/auth/token_denylist/apps.py new file mode 100644 index 00000000..97b28215 --- /dev/null +++ b/taiga/auth/token_denylist/apps.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Denylist app +============= + +This app provides token denylist functionality. + +If the denylist app is detected in ``INSTALLED_APPS``, Taiga Auth will add any +generated refresh token to a list of outstanding tokens. It will also check +that any refresh token does not appear in a denylist of tokens before it +considers it as valid. + +The denylist app implements its outstanding and denylisted token lists using +two models: ``OutstandingToken`` and ``DenylistedToken``. Model admins are +defined for both of these models. To add a token to the denylist, find its +corresponding ``OutstandingToken`` record in the admin and use the admin again +to create a ``DenylistedToken`` record that points to the ``OutstandingToken`` +record. + +Alternatively, you can denylist a token by creating a ``DenylistMixin`` +subclass instance and calling the instance's ``denylist`` method: + +.. code-block:: python + + from rest_framework_simplejwt.tokens import RefreshToken + + token = RefreshToken(base64_encoded_token_string) + token.denylist() + +This will create unique outstanding token and denylist records for the token's +"jti" claim or whichever claim is specified by the ``JTI_CLAIM`` setting. + +The denylist app also provides a management command, ``flushexpiredtokens``, +which will delete any tokens from the outstanding list and denylist that have +expired. +""" + +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class TokenDenylistConfig(AppConfig): + name = 'taiga.auth.token_denylist' + verbose_name = _('Token Denylist') + default_auto_field = 'django.db.models.BigAutoField' diff --git a/taiga/auth/token_denylist/management/__init__.py b/taiga/auth/token_denylist/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/auth/token_denylist/management/commands/__init__.py b/taiga/auth/token_denylist/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/auth/token_denylist/management/commands/flushexpiredtokens.py b/taiga/auth/token_denylist/management/commands/flushexpiredtokens.py new file mode 100644 index 00000000..074141f7 --- /dev/null +++ b/taiga/auth/token_denylist/management/commands/flushexpiredtokens.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from django.core.management.base import BaseCommand + +from taiga.auth.utils import aware_utcnow + +from ...models import OutstandingToken + + +class Command(BaseCommand): + help = 'Flushes any expired tokens in the outstanding token list' + + def handle(self, *args, **kwargs): + OutstandingToken.objects.filter(expires_at__lte=aware_utcnow()).delete() diff --git a/taiga/auth/token_denylist/migrations/0001_initial.py b/taiga/auth/token_denylist/migrations/0001_initial.py new file mode 100644 index 00000000..ce703977 --- /dev/null +++ b/taiga/auth/token_denylist/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL + +# Generated by Django 2.2.23 on 2021-06-23 09:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OutstandingToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('jti', models.CharField(max_length=255, unique=True)), + ('token', models.TextField()), + ('created_at', models.DateTimeField(blank=True, null=True)), + ('expires_at', models.DateTimeField()), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('user',), + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DenylistedToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('denylisted_at', models.DateTimeField(auto_now_add=True)), + ('token', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='token_denylist.OutstandingToken')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/taiga/auth/token_denylist/migrations/__init__.py b/taiga/auth/token_denylist/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/auth/token_denylist/models.py b/taiga/auth/token_denylist/models.py new file mode 100644 index 00000000..629dc076 --- /dev/null +++ b/taiga/auth/token_denylist/models.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from django.conf import settings +from django.db import models + + +class OutstandingToken(models.Model): + id = models.BigAutoField(primary_key=True, serialize=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True) + + jti = models.CharField(unique=True, max_length=255) + token = models.TextField() + + created_at = models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField() + + class Meta: + # Work around for a bug in Django: + # https://code.djangoproject.com/ticket/19422 + # + # Also see corresponding ticket: + # https://github.com/encode/django-rest-framework/issues/705 + abstract = 'taiga.auth.token_denylist' not in settings.INSTALLED_APPS + ordering = ('user',) + + def __str__(self): + return 'Token for {} ({})'.format( + self.user, + self.jti, + ) + + +class DenylistedToken(models.Model): + id = models.BigAutoField(primary_key=True, serialize=False) + token = models.OneToOneField(OutstandingToken, on_delete=models.CASCADE) + + denylisted_at = models.DateTimeField(auto_now_add=True) + + class Meta: + # Work around for a bug in Django: + # https://code.djangoproject.com/ticket/19422 + # + # Also see corresponding ticket: + # https://github.com/encode/django-rest-framework/issues/705 + abstract = 'taiga.auth.token_denylist' not in settings.INSTALLED_APPS + + def __str__(self): + return 'Denylisted token for {}'.format(self.token.user) diff --git a/taiga/auth/token_denylist/tasks.py b/taiga/auth/token_denylist/tasks.py new file mode 100644 index 00000000..55fcfaf4 --- /dev/null +++ b/taiga/auth/token_denylist/tasks.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL + + +from django.core.management import call_command + +from taiga.celery import app + + +@app.task +def flush_expired_tokens(): + """Flushes any expired tokens in the outstanding token list.""" + call_command('flushexpiredtokens') diff --git a/taiga/auth/tokens.py b/taiga/auth/tokens.py index efe1284b..96a78ae4 100644 --- a/taiga/auth/tokens.py +++ b/taiga/auth/tokens.py @@ -4,48 +4,323 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. # # Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. -from django.contrib.auth import get_user_model -from taiga.base import exceptions as exc +from datetime import timedelta +from uuid import uuid4 -from django.apps import apps -from django.core import signing -from django.utils.translation import ugettext as _ +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from .exceptions import TokenBackendError, TokenError +from .settings import api_settings +from .token_denylist.models import DenylistedToken, OutstandingToken +from .utils import ( + aware_utcnow, datetime_from_epoch, datetime_to_epoch, format_lazy, +) -def get_token_for_user(user, scope): +class Token: """ - Generate a new signed token containing - a specified user limited for a scope (identified as a string). + A class which validates and wraps an existing JWT or can be used to build a + new JWT. """ - data = {"user_%s_id" % (scope): user.id} - return signing.dumps(data) + token_type = None + lifetime = None + + def __init__(self, token=None, verify=True): + """ + !!!! IMPORTANT !!!! MUST raise a TokenError with a user-facing error + message if the given token is invalid, expired, or otherwise not safe + to use. + """ + if self.token_type is None or self.lifetime is None: + raise TokenError(_('Cannot create token with no type or lifetime')) + + self.token = token + self.current_time = aware_utcnow() + + # Set up token + if token is not None: + # An encoded token was provided + token_backend = self.get_token_backend() + + # Decode token + try: + self.payload = token_backend.decode(token, verify=verify) + except TokenBackendError: + raise TokenError(_('Token is invalid or expired')) + + if verify: + self.verify() + else: + # New token. Skip all the verification steps. + self.payload = {api_settings.TOKEN_TYPE_CLAIM: self.token_type} + + # Set "exp" claim with default value + self.set_exp(from_time=self.current_time, lifetime=self.lifetime) + + # Set "jti" claim + self.set_jti() + + def __repr__(self): + return repr(self.payload) + + def __getitem__(self, key): + return self.payload[key] + + def __setitem__(self, key, value): + self.payload[key] = value + + def __delitem__(self, key): + del self.payload[key] + + def __contains__(self, key): + return key in self.payload + + def get(self, key, default=None): + return self.payload.get(key, default) + + def __str__(self): + """ + Signs and returns a token as a base64 encoded string. + """ + return self.get_token_backend().encode(self.payload) + + def verify(self): + """ + Performs additional validation steps which were not performed when this + token was decoded. This method is part of the "public" API to indicate + the intention that it may be overridden in subclasses. + """ + # According to RFC 7519, the "exp" claim is OPTIONAL + # (https://tools.ietf.org/html/rfc7519#section-4.1.4). As a more + # correct behavior for authorization tokens, we require an "exp" + # claim. We don't want any zombie tokens walking around. + self.check_exp() + + # Ensure token id is present + if api_settings.JTI_CLAIM not in self.payload: + raise TokenError(_('Token has no id')) + + self.verify_token_type() + + def verify_token_type(self): + """ + Ensures that the token type claim is present and has the correct value. + """ + try: + token_type = self.payload[api_settings.TOKEN_TYPE_CLAIM] + except KeyError: + raise TokenError(_('Token has no type')) + + if self.token_type != token_type: + raise TokenError(_('Token has wrong type')) + + def set_jti(self): + """ + Populates the configured jti claim of a token with a string where there + is a negligible probability that the same string will be chosen at a + later time. + + See here: + https://tools.ietf.org/html/rfc7519#section-4.1.7 + """ + self.payload[api_settings.JTI_CLAIM] = uuid4().hex + + def set_exp(self, claim='exp', from_time=None, lifetime=None): + """ + Updates the expiration time of a token. + """ + if from_time is None: + from_time = self.current_time + + if lifetime is None: + lifetime = self.lifetime + + self.payload[claim] = datetime_to_epoch(from_time + lifetime) + + def check_exp(self, claim='exp', current_time=None): + """ + Checks whether a timestamp value in the given claim has passed (since + the given datetime value in `current_time`). Raises a TokenError with + a user-facing error message if so. + """ + if current_time is None: + current_time = self.current_time + + try: + claim_value = self.payload[claim] + except KeyError: + raise TokenError(format_lazy(_("Token has no '{}' claim"), claim)) + + claim_time = datetime_from_epoch(claim_value) + if claim_time <= current_time: + raise TokenError(format_lazy(_("Token '{}' claim has expired"), claim)) + + @classmethod + def for_user(cls, user): + """ + Returns an authorization token for the given user that will be provided + after authenticating the user's credentials. + """ + user_id = getattr(user, api_settings.USER_ID_FIELD) + if not isinstance(user_id, int): + user_id = str(user_id) + + token = cls() + token[api_settings.USER_ID_CLAIM] = user_id + + return token + + def get_token_backend(self): + from .state import token_backend + return token_backend -def get_user_for_token(token, scope, max_age=None): +class DenylistMixin: """ - Given a selfcontained token and a scope try to parse and - unsign it. - - If max_age is specified it checks token expiration. - - If token passes a validation, returns - a user instance corresponding with user_id stored - in the incoming token. + If the `taiga.auth.token_denylist` app was configured to be + used, tokens created from `DenylistMixin` subclasses will insert + themselves into an outstanding token list and also check for their + membership in a token denylist. """ - try: - data = signing.loads(token, max_age=max_age) - except signing.BadSignature: - raise exc.NotAuthenticated(_("Invalid token")) + if 'taiga.auth.token_denylist' in settings.INSTALLED_APPS: + def verify(self, *args, **kwargs): + self.check_denylist() - model_cls = get_user_model() + super().verify(*args, **kwargs) - try: - user = model_cls.objects.get( - pk=data[f"user_{scope}_id"], - is_active=True - ) - except (model_cls.DoesNotExist, KeyError): - raise exc.NotAuthenticated(_("Invalid token")) - else: - return user + def check_denylist(self): + """ + Checks if this token is present in the token denylist. Raises + `TokenError` if so. + """ + jti = self.payload[api_settings.JTI_CLAIM] + + if DenylistedToken.objects.filter(token__jti=jti).exists(): + raise TokenError(_('Token is denylisted')) + + def denylist(self): + """ + Ensures this token is included in the outstanding token list and + adds it to the denylist. + """ + jti = self.payload[api_settings.JTI_CLAIM] + exp = self.payload['exp'] + + # Ensure outstanding token exists with given jti + token, _ = OutstandingToken.objects.get_or_create( + jti=jti, + defaults={ + 'token': str(self), + 'expires_at': datetime_from_epoch(exp), + }, + ) + + return DenylistedToken.objects.get_or_create(token=token) + + @classmethod + def for_user(cls, user): + """ + Adds this token to the outstanding token list. + """ + token = super().for_user(user) + + jti = token[api_settings.JTI_CLAIM] + exp = token['exp'] + + OutstandingToken.objects.create( + user=user, + jti=jti, + token=str(token), + created_at=token.current_time, + expires_at=datetime_from_epoch(exp), + ) + + return token + + +class RefreshToken(DenylistMixin, Token): + token_type = 'refresh' + lifetime = api_settings.REFRESH_TOKEN_LIFETIME + no_copy_claims = ( + api_settings.TOKEN_TYPE_CLAIM, + 'exp', + + # Both of these claims are included even though they may be the same. + # It seems possible that a third party token might have a custom or + # namespaced JTI claim as well as a default "jti" claim. In that case, + # we wouldn't want to copy either one. + api_settings.JTI_CLAIM, + 'jti', + ) + + @property + def access_token(self): + """ + Returns an access token created from this refresh token. Copies all + claims present in this refresh token to the new access token except + those claims listed in the `no_copy_claims` attribute. + """ + access = AccessToken() + + # Use instantiation time of refresh token as relative timestamp for + # access token "exp" claim. This ensures that both a refresh and + # access token expire relative to the same time if they are created as + # a pair. + access.set_exp(from_time=self.current_time) + + no_copy = self.no_copy_claims + for claim, value in self.payload.items(): + if claim in no_copy: + continue + access[claim] = value + + return access + + +class AccessToken(Token): + token_type = 'access' + lifetime = api_settings.ACCESS_TOKEN_LIFETIME + + +class CancelToken(Token): + token_type = 'cancel_account' + lifetime = timedelta(days=365) + + +class UntypedToken(Token): + token_type = 'untyped' + lifetime = timedelta(seconds=0) + + def verify_token_type(self): + """ + Untyped tokens do not verify the "token_type" claim. This is useful + when performing general validation of a token's signature and other + properties which do not relate to the token's intended use. + """ + pass diff --git a/taiga/auth/utils.py b/taiga/auth/utils.py new file mode 100644 index 00000000..2ab4492c --- /dev/null +++ b/taiga/auth/utils.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from calendar import timegm +from datetime import datetime + +from django.conf import settings +from django.utils.functional import lazy +from django.utils.timezone import is_naive, make_aware, utc + + +def make_utc(dt): + if settings.USE_TZ and is_naive(dt): + return make_aware(dt, timezone=utc) + + return dt + + +def aware_utcnow(): + return make_utc(datetime.utcnow()) + + +def datetime_to_epoch(dt): + return timegm(dt.utctimetuple()) + + +def datetime_from_epoch(ts): + return make_utc(datetime.utcfromtimestamp(ts)) + + +def format_lazy(s, *args, **kwargs): + return s.format(*args, **kwargs) + + +format_lazy = lazy(format_lazy, str) diff --git a/taiga/auth/validators.py b/taiga/auth/validators.py deleted file mode 100644 index c0b5a8e4..00000000 --- a/taiga/auth/validators.py +++ /dev/null @@ -1,53 +0,0 @@ -# -*- coding: utf-8 -*- -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# -# Copyright (c) 2021-present Kaleidos Ventures SL - -import bleach - -from django.core import validators as core_validators -from django.utils.translation import ugettext as _ - -from taiga.base.api import serializers -from taiga.base.api import validators -from taiga.base.exceptions import ValidationError - -import re - - -class BaseRegisterValidator(validators.Validator): - full_name = serializers.CharField(max_length=36) - email = serializers.EmailField(max_length=255) - username = serializers.CharField(max_length=255) - password = serializers.CharField(min_length=6) - - def validate_username(self, attrs, source): - value = attrs[source] - validator = core_validators.RegexValidator(re.compile(r'^[\w.-]+$'), _("invalid username"), "invalid") - - try: - validator(value) - except ValidationError: - raise ValidationError(_("Required. 255 characters or fewer. Letters, numbers " - "and /./-/_ characters'")) - return attrs - - def validate_full_name(self, attrs, source): - value = attrs[source] - if value != bleach.clean(value): - raise ValidationError(_("Invalid full name")) - - if re.search(r"http[s]?:", value): - raise ValidationError(_("Invalid full name")) - - return attrs - - -class PublicRegisterValidator(BaseRegisterValidator): - pass - - -class PrivateRegisterValidator(BaseRegisterValidator): - token = serializers.CharField(max_length=255, required=True) diff --git a/taiga/base/api/generics.py b/taiga/base/api/generics.py index 5ce6073f..da2acc06 100644 --- a/taiga/base/api/generics.py +++ b/taiga/base/api/generics.py @@ -145,7 +145,7 @@ class GenericAPIView(pagination.PaginationMixin, ########################################################### def get_serializer_class(self): - if self.action == "list" and hasattr(self, "list_serializer_class"): + if hasattr(self, "action") and self.action == "list" and hasattr(self, "list_serializer_class"): return self.list_serializer_class serializer_class = self.serializer_class diff --git a/taiga/celery.py b/taiga/celery.py index 9f23fb45..6b3420df 100644 --- a/taiga/celery.py +++ b/taiga/celery.py @@ -37,3 +37,11 @@ if settings.SEND_BULK_EMAILS_WITH_CELERY and settings.CHANGE_NOTIFICATIONS_MIN_I 'schedule': settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL, 'args': (), } + +if ('taiga.auth.token_denylist' in settings.INSTALLED_APPS and + getattr(settings, "FLUSH_REFRESHED_TOKENS_PERIODICITY", None)): + app.conf.beat_schedule['auth-flush-expired-tokens'] = { + 'task': 'taiga.auth.token_denylist.tasks.flush_expired_tokens', + 'schedule': settings.FLUSH_REFRESHED_TOKENS_PERIODICITY, + 'args': (), + } diff --git a/taiga/front/urls.py b/taiga/front/urls.py index 74960962..2d591f63 100644 --- a/taiga/front/urls.py +++ b/taiga/front/urls.py @@ -18,7 +18,7 @@ urls = { "change-password": "/change-password/{0}", # user.token "change-email": "/change-email/{0}", # user.email_token "verify-email": "/verify-email/{0}", # user.email_token - "cancel-account": "/cancel-account/{0}", # auth.token.get_token_for_user(user) + "cancel-account": "/cancel-account/{0}", # auth.tokens.CancelToken.for_user(user) "invitation": "/invitation/{0}", # membership.token "user": "/profile/{0}", # user.username diff --git a/taiga/projects/services/invitations.py b/taiga/projects/services/invitations.py index af62ebd9..366a49a4 100644 --- a/taiga/projects/services/invitations.py +++ b/taiga/projects/services/invitations.py @@ -7,6 +7,12 @@ from django.apps import apps from django.conf import settings +from django.contrib.auth import get_user_model +from django.db import transaction as tx +from django.db import IntegrityError +from django.utils.translation import ugettext as _ + +from taiga.base import exceptions as exc from taiga.base.mails import mail_builder @@ -34,9 +40,41 @@ def find_invited_user(email, default=None): :return: The user if it's found, othwerwise return `default`. """ - User = apps.get_model(settings.AUTH_USER_MODEL) + user_model = apps.get_model(settings.AUTH_USER_MODEL) try: - return User.objects.get(email=email) - except User.DoesNotExist: + return user_model.objects.get(email=email) + except user_model.DoesNotExist: return default + + +def get_membership_by_token(token:str): + """ + Given an invitation token, returns a membership instance + that matches with specified token. + + If not matches with any membership NotFound exception + is raised. + """ + membership_model = apps.get_model("projects", "Membership") + qs = membership_model.objects.filter(token=token) + if len(qs) == 0: + raise exc.NotFound(_("Token does not match any valid invitation.")) + return qs[0] + + +@tx.atomic +def accept_invitation_by_existing_user(token:str, user_id:int): + user_model = get_user_model() + try: + user = user_model.objects.get(id=user_id) + except user_model.DoesNotExist: + raise exc.NotFound(_("User does not exist.")) + + membership = get_membership_by_token(token) + try: + membership.user = user + membership.save(update_fields=["user"]) + except IntegrityError: + raise exc.IntegrityError(_("This user is already a member of the project.")) + return user diff --git a/taiga/urls.py b/taiga/urls.py index aed6f5e1..84360ebd 100644 --- a/taiga/urls.py +++ b/taiga/urls.py @@ -10,7 +10,6 @@ from django.conf.urls import include, url from django.contrib import admin from django.urls import path - from .routers import router diff --git a/taiga/users/api.py b/taiga/users/api.py index c5224299..d9feb83e 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -13,16 +13,18 @@ from django.core.validators import validate_email from django.core.exceptions import ValidationError from django.conf import settings +from taiga.auth.exceptions import TokenError +from taiga.auth.tokens import CancelToken +from taiga.auth.settings import api_settings as auth_settings from taiga.base import exceptions as exc from taiga.base import filters from taiga.base import response from taiga.base.utils.dicts import into_namedtuple -from taiga.auth.tokens import get_user_for_token from taiga.base.decorators import list_route from taiga.base.decorators import detail_route -from taiga.base.api import ModelCrudViewSet -from taiga.base.api.mixins import BlockedByProjectMixin from taiga.base.api.fields import validate_user_email_allowed_domains +from taiga.base.api.mixins import BlockedByProjectMixin +from taiga.base.api.viewsets import ModelCrudViewSet from taiga.base.api.utils import get_object_or_404 from taiga.base.filters import MembersFilterBackend from taiga.base.mails import mail_builder @@ -310,11 +312,10 @@ class UsersViewSet(ModelCrudViewSet): raise exc.WrongArguments(_("Invalid, are you sure the token is correct?")) try: - max_age_cancel_account = getattr(settings, "MAX_AGE_CANCEL_ACCOUNT", None) - user = get_user_for_token(validator.data["cancel_token"], "cancel_account", - max_age=max_age_cancel_account) - - except exc.NotAuthenticated: + validated_token = CancelToken(token=validator.data["cancel_token"]) + user_id_value = validated_token[auth_settings.USER_ID_CLAIM] + user = models.User.objects.get(**{auth_settings.USER_ID_FIELD: user_id_value}) + except (exc.NotAuthenticated, models.User.DoesNotExist, TokenError, KeyError): raise exc.WrongArguments(_("Invalid, are you sure the token is correct?")) if not user.is_active: diff --git a/taiga/users/models.py b/taiga/users/models.py index fe016aa0..993adcfb 100644 --- a/taiga/users/models.py +++ b/taiga/users/models.py @@ -28,7 +28,6 @@ from django.utils.translation import ugettext_lazy as _ from taiga.base.db.models.fields import JSONField from django_pglocks import advisory_lock -from taiga.auth.tokens import get_token_for_user from taiga.base.utils.colors import generate_random_hex_color from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.files import get_file_path @@ -267,10 +266,6 @@ class User(AbstractBaseUser, PermissionsMixin): qs = qs.exclude(id=self.id) return qs - def save(self, *args, **kwargs): - get_token_for_user(self, "cancel_account") - super().save(*args, **kwargs) - def cancel(self): with advisory_lock("delete-user"): deleted_user_prefix = "deleted-user-{}".format(timestamp_ms()) diff --git a/taiga/users/services.py b/taiga/users/services.py index c1aeef29..9b959dbc 100644 --- a/taiga/users/services.py +++ b/taiga/users/services.py @@ -60,7 +60,7 @@ def get_and_validate_user(*, username: str, password: str) -> bool: """ user = get_user_by_username_or_email(username) - if not user.check_password(password): + if not user.check_password(password) or not user.is_active or user.is_system: raise exc.WrongArguments(_("Username or password does not matches user.")) return user diff --git a/taiga/users/validators.py b/taiga/users/validators.py index f995b265..1b02eaba 100644 --- a/taiga/users/validators.py +++ b/taiga/users/validators.py @@ -88,7 +88,7 @@ class ChangeEmailValidator(validators.Validator): class CancelAccountValidator(validators.Validator): - cancel_token = serializers.CharField(max_length=200) + cancel_token = serializers.CharField() ###################################################### diff --git a/tests/config.py b/tests/config.py index bd460935..c7fcb33e 100644 --- a/tests/config.py +++ b/tests/config.py @@ -9,6 +9,8 @@ from settings.common import * # noqa, pylint: disable=unused-wildcard-import DEBUG = True +SECRET_KEY = "not very secret in tests" + TEMPLATES[0]["OPTIONS"]['context_processors'] += "django.template.context_processors.debug" CELERY_ENABLED = False diff --git a/tests/integration/resources_permissions/test_auth_resources.py b/tests/integration/resources_permissions/test_auth_resources.py index d8c63fa1..4768ce43 100644 --- a/tests/integration/resources_permissions/test_auth_resources.py +++ b/tests/integration/resources_permissions/test_auth_resources.py @@ -39,6 +39,30 @@ def test_auth_create(client): assert result.status_code == 200 +def test_auth_refresh(client): + url = reverse('auth-list') + + user = f.UserFactory.create() + + login_data = json.dumps({ + "type": "normal", + "username": user.username, + "password": user.username, + }) + + result = client.post(url, login_data, content_type="application/json") + assert result.status_code == 200 + + url = reverse('auth-refresh') + + refresh_data = json.dumps({ + "refresh": result.data["refresh"], + }) + + result = client.post(url, refresh_data, content_type="application/json") + assert result.status_code == 200 + + def test_auth_action_register_with_short_password(client, settings): settings.PUBLIC_REGISTER_ENABLED = True url = reverse('auth-register') diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth.py similarity index 65% rename from tests/integration/test_auth_api.py rename to tests/integration/test_auth.py index abb0c8b2..703f024d 100644 --- a/tests/integration/test_auth_api.py +++ b/tests/integration/test_auth.py @@ -24,6 +24,9 @@ def register_form(): "accepted_terms": True, "type": "public"} +################# +# registration +################# def test_respond_201_when_public_registration_is_enabled(client, settings, register_form): settings.PUBLIC_REGISTER_ENABLED = True @@ -51,23 +54,6 @@ def test_respond_201_when_the_email_domain_is_in_allowed_domains(client, setting assert response.status_code == 201 -def test_respond_201_with_invitation_login(client, settings): - settings.PUBLIC_REGISTER_ENABLED = False - user = factories.UserFactory() - membership = factories.MembershipFactory(user=user) - - auth_data = { - "type": "normal", - "invitation_token": membership.token, - "username": user.username, - "password": user.username, - } - - response = client.post(reverse("auth-list"), auth_data) - - assert response.status_code == 200, response.data - - def test_response_200_in_public_registration(client, settings): settings.PUBLIC_REGISTER_ENABLED = True form = { @@ -112,6 +98,273 @@ def test_respond_400_if_username_or_email_is_duplicate(client, settings, registe assert response.status_code == 400 +def test_register_success_throttling(client, settings): + settings.PUBLIC_REGISTER_ENABLED = True + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["register-success"] = "1/minute" + + register_form = {"username": "valid_username_register_success", + "password": "valid_password", + "full_name": "fullname", + "email": "", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + register_form = {"username": "valid_username_register_success", + "password": "valid_password", + "full_name": "fullname", + "email": "valid_username_register_success@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 201 + + register_form = {"username": "valid_username_register_success2", + "password": "valid_password2", + "full_name": "fullname", + "email": "valid_username_register_success2@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 429 + + register_form = {"username": "valid_username_register_success2", + "password": "valid_password2", + "full_name": "fullname", + "email": "", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 429 + + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["register-success"] = None + + +INVALID_NAMES = [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod", + "an example", + "http://testdomain.com", + "https://testdomain.com", + "Visit http://testdomain.com", +] + +@pytest.mark.parametrize("full_name", INVALID_NAMES) +def test_register_sanitize_invalid_user_full_name(client, settings, full_name, register_form): + settings.PUBLIC_REGISTER_ENABLED = True + register_form["full_name"] = full_name + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + +VALID_NAMES = [ + "martin seamus mcfly" +] + +@pytest.mark.parametrize("full_name", VALID_NAMES) +def test_register_sanitize_valid_user_full_name(client, settings, full_name, register_form): + settings.PUBLIC_REGISTER_ENABLED = True + register_form["full_name"] = full_name + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 201 + + +def test_registration_case_insensitive_for_username_and_password(client, settings): + settings.PUBLIC_REGISTER_ENABLED = True + + register_form = {"username": "Username", + "password": "password", + "full_name": "fname", + "email": "User@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 201 + + # Email is case insensitive in the register process + register_form = {"username": "username2", + "password": "password", + "full_name": "fname", + "email": "user@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + # Username is case insensitive in the register process too + register_form = {"username": "username", + "password": "password", + "full_name": "fname", + "email": "user2@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + +################# +# autehtication +################# + +def test_get_auth_token_with_username(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory() + + auth_data = { + "username": user.username, + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 200, response.data + assert "auth_token" in response.data and response.data["auth_token"] + assert "refresh" in response.data and response.data["refresh"] + + +def test_get_auth_token_with_username_case_insensitive(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory() + + auth_data = { + "username": user.username.upper(), + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 200, response.data + assert "auth_token" in response.data and response.data["auth_token"] + assert "refresh" in response.data and response.data["refresh"] + + +def test_get_auth_token_with_email(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory() + + auth_data = { + "username": user.email, + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 200, response.data + assert "auth_token" in response.data and response.data["auth_token"] + assert "refresh" in response.data and response.data["refresh"] + + +def test_get_auth_token_with_email_case_insensitive(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory() + + auth_data = { + "username": user.email.upper(), + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 200, response.data + assert "auth_token" in response.data and response.data["auth_token"] + assert "refresh" in response.data and response.data["refresh"] + + +def test_get_auth_token_with_project_invitation(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory() + membership = factories.MembershipFactory(user=None) + + auth_data = { + "username": user.username, + "password": user.username, + "type": "normal", + "invitation_token": membership.token, + } + + assert membership.user == None + + response = client.post(reverse("auth-list"), auth_data) + membership.refresh_from_db() + + assert response.status_code == 200, response.data + assert "auth_token" in response.data and response.data["auth_token"] + assert "refresh" in response.data and response.data["refresh"] + assert membership.user == user + + +def test_get_auth_token_error_invalid_credentials(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory() + + auth_data = { + "username": "bad username", + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 401, response.data + + auth_data = { + "username": user.username, + "password": "invalid password", + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 401, response.data + + +def test_get_auth_token_error_inactive_user(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory(is_active=False) + + auth_data = { + "username": user.username, + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 401, response.data + + +def test_get_auth_token_error_inactive_user(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory(is_active=False) + + auth_data = { + "username": user.username, + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 401, response.data + +def test_get_auth_token_error_system_user(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory(is_system=True) + + auth_data = { + "username": user.username, + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 401, response.data + + def test_auth_uppercase_ignore(client, settings): settings.PUBLIC_REGISTER_ENABLED = True @@ -187,7 +440,7 @@ def test_auth_uppercase_ignore(client, settings): "password": "password"} response = client.post(reverse("auth-list"), login_form) - assert response.status_code == 400 + assert response.status_code == 401 # neither with the email login_form = {"type": "normal", @@ -195,7 +448,7 @@ def test_auth_uppercase_ignore(client, settings): "password": "password"} response = client.post(reverse("auth-list"), login_form) - assert response.status_code == 400 + assert response.status_code == 401 def test_login_fail_throttling(client, settings): @@ -215,100 +468,28 @@ def test_login_fail_throttling(client, settings): "password": "valid_password"} response = client.post(reverse("auth-list"), login_form) - assert response.status_code == 200 + assert response.status_code == 200, response.data login_form = {"type": "normal", "username": "invalid_username_login_fail", "password": "invalid_password"} response = client.post(reverse("auth-list"), login_form) - assert response.status_code == 400 + assert response.status_code == 401, response.data login_form = {"type": "normal", "username": "invalid_username_login_fail", "password": "invalid_password"} response = client.post(reverse("auth-list"), login_form) - assert response.status_code == 429 + assert response.status_code == 429, response.data login_form = {"type": "normal", "username": "valid_username_login_fail", "password": "valid_password"} response = client.post(reverse("auth-list"), login_form) - assert response.status_code == 429 + assert response.status_code == 429, response.data settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["login-fail"] = None -def test_register_success_throttling(client, settings): - settings.PUBLIC_REGISTER_ENABLED = True - settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["register-success"] = "1/minute" - - register_form = {"username": "valid_username_register_success", - "password": "valid_password", - "full_name": "fullname", - "email": "", - "accepted_terms": True, - "type": "public"} - response = client.post(reverse("auth-register"), register_form) - assert response.status_code == 400 - - register_form = {"username": "valid_username_register_success", - "password": "valid_password", - "full_name": "fullname", - "email": "valid_username_register_success@email.com", - "accepted_terms": True, - "type": "public"} - response = client.post(reverse("auth-register"), register_form) - assert response.status_code == 201 - - register_form = {"username": "valid_username_register_success2", - "password": "valid_password2", - "full_name": "fullname", - "email": "valid_username_register_success2@email.com", - "accepted_terms": True, - "type": "public"} - response = client.post(reverse("auth-register"), register_form) - assert response.status_code == 429 - - register_form = {"username": "valid_username_register_success2", - "password": "valid_password2", - "full_name": "fullname", - "email": "", - "accepted_terms": True, - "type": "public"} - response = client.post(reverse("auth-register"), register_form) - assert response.status_code == 429 - - settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["register-success"] = None - - -INVALID_NAMES = [ - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod", - "an example", - "http://testdomain.com", - "https://testdomain.com", - "Visit http://testdomain.com", -] - -@pytest.mark.parametrize("full_name", INVALID_NAMES) -def test_register_sanitize_invalid_user_full_name(client, settings, full_name, register_form): - settings.PUBLIC_REGISTER_ENABLED = True - register_form["full_name"] = full_name - response = client.post(reverse("auth-register"), register_form) - assert response.status_code == 400 - -VALID_NAMES = [ - "martin seamus mcfly" -] - -@pytest.mark.parametrize("full_name", VALID_NAMES) -def test_register_sanitize_valid_user_full_name(client, settings, full_name, register_form): - settings.PUBLIC_REGISTER_ENABLED = True - register_form["full_name"] = full_name - response = client.post(reverse("auth-register"), register_form) - assert response.status_code == 201 - - - - diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 9dec27e6..b065fa51 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -36,7 +36,7 @@ import pytest from unittest import mock -pytestmark = pytest.mark.django_db(transaction=True) +pytestmark = pytest.mark.django_db class ExpiredSigner(signing.TimestampSigner): @@ -270,13 +270,13 @@ def test_us_status_is_closed_changed_recalc_us_is_closed(client): us_status.is_closed = True us_status.save() - user_story = user_story.__class__.objects.get(pk=user_story.pk) + user_story.refresh_from_db() assert user_story.is_closed is True us_status.is_closed = False us_status.save() - user_story = user_story.__class__.objects.get(pk=user_story.pk) + user_story.refresh_from_db() assert user_story.is_closed is False @@ -581,6 +581,7 @@ def test_destroy_point_and_reassign(client): assert project.default_points.id == p2.id +@pytest.mark.django_db(transaction=True) def test_update_projects_order_in_bulk(client): user = f.create_user() client.login(user) diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py index b34396ec..330d7f37 100644 --- a/tests/integration/test_users.py +++ b/tests/integration/test_users.py @@ -23,7 +23,7 @@ from taiga.base.utils.thumbnails import get_thumbnail_url from taiga.base.utils.dicts import into_namedtuple from taiga.users import models from taiga.users.serializers import LikedObjectSerializer, VotedObjectSerializer -from taiga.auth.tokens import get_token_for_user +from taiga.auth.tokens import AccessToken, CancelToken from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS from taiga.projects import choices as project_choices from taiga.users.services import get_watched_list, get_voted_list, get_liked_list @@ -320,7 +320,7 @@ def test_delete_self_user_remove_membership_projects(client): def test_deleted_user_can_not_use_its_token(client): user = f.UserFactory.create() - token = get_token_for_user(user, "authentication") + token = AccessToken.for_user(user) headers = {'HTTP_AUTHORIZATION': f'Bearer {token}'} url = reverse('users-me') @@ -341,8 +341,8 @@ def test_deleted_user_can_not_use_its_token(client): def test_cancel_self_user_with_valid_token(client): user = f.UserFactory.create() url = reverse('users-cancel') - cancel_token = get_token_for_user(user, "cancel_account") - data = {"cancel_token": cancel_token} + cancel_token = CancelToken.for_user(user) + data = {"cancel_token": str(cancel_token)} client.login(user) response = client.post(url, json.dumps(data), content_type="application/json") @@ -351,10 +351,26 @@ def test_cancel_self_user_with_valid_token(client): assert user.full_name == "Deleted user" +def test_cancel_self_user_with_valid_token_but_inactive(client): + user = f.UserFactory.create(is_active=False) + url = reverse('users-cancel') + cancel_token = CancelToken.for_user(user) + data = {"cancel_token": str(cancel_token)} + client.login(user) + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 400 + def test_cancel_self_user_with_invalid_token(client): user = f.UserFactory.create() url = reverse('users-cancel') - data = {"cancel_token": "invalid_cancel_token"} + data = {"cancel_token": str(CancelToken())} + client.login(user) + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 400 + + data = {"cancel_token": "__invalid_token__"} client.login(user) response = client.post(url, json.dumps(data), content_type="application/json") @@ -363,9 +379,9 @@ def test_cancel_self_user_with_invalid_token(client): def test_cancel_self_user_with_date_cancelled(client): user = f.UserFactory.create() + cancel_token = CancelToken.for_user(user) url = reverse('users-cancel') - cancel_token = get_token_for_user(user, "cancel_account") - data = {"cancel_token": cancel_token} + data = {"cancel_token": str(cancel_token)} client.login(user) response = client.post(url, json.dumps(data), content_type="application/json") diff --git a/tests/unit/conftest.py b/tests/unit/auth/__init__.py similarity index 73% rename from tests/unit/conftest.py rename to tests/unit/auth/__init__.py index 9e108c13..b92267f0 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/auth/__init__.py @@ -5,8 +5,3 @@ # # Copyright (c) 2021-present Kaleidos Ventures SL -from ..utils import disconnect_signals - - -def pytest_runtest_setup(item): - disconnect_signals() diff --git a/tests/unit/auth/test_authentication.py b/tests/unit/auth/test_authentication.py new file mode 100644 index 00000000..a1e56c45 --- /dev/null +++ b/tests/unit/auth/test_authentication.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest + +from datetime import timedelta +from importlib import reload + +from taiga.auth import authentication +from taiga.auth.exceptions import ( + AuthenticationFailed, InvalidToken, +) +from taiga.auth.models import TokenUser +from taiga.auth.settings import api_settings +from taiga.auth.tokens import AccessToken, CancelToken + +from tests import factories as f + +from .utils import override_api_settings, APIRequestFactory + + +################################### +# JWTAuthentication +################################### + +factory = APIRequestFactory() + +fake_token = b'TokenMcTokenface' +fake_header = b'Bearer ' + fake_token + + +def test_jwt_authentication_get_header(backend = authentication.JWTAuthentication()): + # Should return None if no authorization header + request = factory.get('/test-url/') + assert backend.get_header(request) is None + + # Should pull correct header off request + request = factory.get('/test-url/', HTTP_AUTHORIZATION=fake_header) + assert backend.get_header(request) == fake_header + + # Should work for unicode headers + request = factory.get('/test-url/', HTTP_AUTHORIZATION=fake_header.decode('utf-8')) + assert backend.get_header(request) == fake_header + + # Should work with the x_access_token + with override_api_settings(AUTH_HEADER_NAME='HTTP_X_ACCESS_TOKEN'): + # Should pull correct header off request when using X_ACCESS_TOKEN + request = factory.get('/test-url/', HTTP_X_ACCESS_TOKEN=fake_header) + assert backend.get_header(request) == fake_header + + # Should work for unicode headers when using + request = factory.get('/test-url/', HTTP_X_ACCESS_TOKEN=fake_header.decode('utf-8')) + assert backend.get_header(request) == fake_header + + +def test_jwt_authentication_get_raw_token(backend = authentication.JWTAuthentication()): + # Should return None if header lacks correct type keyword + with override_api_settings(AUTH_HEADER_TYPES='JWT'): + reload(authentication) + assert backend.get_raw_token(fake_header) is None + reload(authentication) + + # Should return None if an empty AUTHORIZATION header is sent + assert backend.get_raw_token(b'') is None + + # Should raise error if header is malformed + with pytest.raises(AuthenticationFailed): + backend.get_raw_token(b'Bearer one two') + + with pytest.raises(AuthenticationFailed): + backend.get_raw_token(b'Bearer') + + # Otherwise, should return unvalidated token in header + assert backend.get_raw_token(fake_header) == fake_token + + # Should return token if header has one of many valid token types + with override_api_settings(AUTH_HEADER_TYPES=('JWT', 'Bearer')): + reload(authentication) + assert backend.get_raw_token(fake_header) == fake_token + + reload(authentication) + + +def test_jwt_authentication_get_validated_token(backend = authentication.JWTAuthentication()): + # Should raise InvalidToken if token not valid + AuthToken = api_settings.AUTH_TOKEN_CLASSES[0] + token = AuthToken() + token.set_exp(lifetime=-timedelta(days=1)) + with pytest.raises(InvalidToken): + backend.get_validated_token(str(token)) + + # Otherwise, should return validated token + token.set_exp() + assert backend.get_validated_token(str(token)).payload == token.payload + + # Should not accept tokens not included in AUTH_TOKEN_CLASSES + cancel_token = CancelToken() + with override_api_settings(AUTH_TOKEN_CLASSES=( + 'taiga.auth.tokens.AccessToken', + )): + with pytest.raises(InvalidToken) as e: + backend.get_validated_token(str(cancel_token)) + + messages = e.value.detail['messages'] + assert len(messages) == 1 + assert { + 'token_class': 'AccessToken', + 'token_type': 'access', + 'message': 'Token has wrong type', + } == messages[0] + + # Should accept tokens included in AUTH_TOKEN_CLASSES + access_token = AccessToken() + cancel_token = CancelToken() + with override_api_settings(AUTH_TOKEN_CLASSES=( + 'taiga.auth.tokens.AccessToken', + 'taiga.auth.tokens.CancelToken', + )): + backend.get_validated_token(str(access_token)) + backend.get_validated_token(str(cancel_token)) + + +@pytest.mark.django_db +def test_jwt_authentication_get_user(backend = authentication.JWTAuthentication()): + payload = {'some_other_id': 'foo'} + + # Should raise error if no recognizable user identification + with pytest.raises(InvalidToken): + backend.get_user(payload) + + payload[api_settings.USER_ID_CLAIM] = 42 + + # Should raise exception if user not found + with pytest.raises(AuthenticationFailed): + backend.get_user(payload) + + u = f.UserFactory(username='markhamill') + u.is_active = False + u.save() + + payload[api_settings.USER_ID_CLAIM] = getattr(u, api_settings.USER_ID_FIELD) + + # Should raise exception if user is inactive + with pytest.raises(AuthenticationFailed): + backend.get_user(payload) + + u.is_active = True + u.save() + + # Otherwise, should return correct user + assert backend.get_user(payload).id == u.id + + +################################### +# JWTAuthentication +################################### + +def test_jwt_token_user_authentication_get_user(backend = authentication.JWTTokenUserAuthentication()): + payload = {'some_other_id': 'foo'} + + # Should raise error if no recognizable user identification + with pytest.raises(InvalidToken): + backend.get_user(payload) + + payload[api_settings.USER_ID_CLAIM] = 42 + + # Otherwise, should return a token user object + user = backend.get_user(payload) + + assert isinstance(user, TokenUser) + assert user.id == 42 + + +def test_jwt_token_user_authentication_custom_tokenuser(backend = authentication.JWTTokenUserAuthentication()): + from django.utils.functional import cached_property + + class BobSaget(TokenUser): + @cached_property + def username(self): + return "bsaget" + + temp = api_settings.TOKEN_USER_CLASS + api_settings.TOKEN_USER_CLASS = BobSaget + + # Should return a token user object + payload = {api_settings.USER_ID_CLAIM: 42} + user = backend.get_user(payload) + + assert isinstance(user, api_settings.TOKEN_USER_CLASS) + assert user.id == 42 + assert user.username == "bsaget" + + # Restore default TokenUser for future tests + api_settings.TOKEN_USER_CLASS = temp diff --git a/tests/unit/auth/test_backends.py b/tests/unit/auth/test_backends.py new file mode 100644 index 00000000..efa21393 --- /dev/null +++ b/tests/unit/auth/test_backends.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from datetime import datetime, timedelta +import pytest +from unittest.mock import patch + +import jwt +from jwt import PyJWS, algorithms + +from taiga.auth.backends import TokenBackend +from taiga.auth.exceptions import TokenBackendError +from taiga.auth.utils import ( + aware_utcnow, datetime_to_epoch, make_utc, +) + +SECRET = 'not_secret' + +PRIVATE_KEY = ''' +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA3xMJfyl8TOdrsjDLSIodsArJ/NnQB3ZdfbFC5onxATDfRLLA +CHFo3ye694doBKeSe1NFYbfXPvahl6ODX1a23oQyoRQwlL+M99cLcdCa0gGuJXdb +AaF6Em8E+7uSb3290mI+rZmjqyc7gMtKVWKL4e5i2PerFFBoYkZ7E90KOp2t0ZAD +x2uqF4VTOfYLHG0cPgSw9/ptDStJqJVAOiRRqbv0j0GOFMDYNcN0mDlnpryhQFbQ +iMqn4IJIURZUVBJujFSa45cJPvSmMb6NrzZ1crg5UN6/5Mu2mxQzAi21+vpgGL+E +EuekUd7sRgEAjTHjLKzotLAGo7EGa8sL1vMSFwIDAQABAoIBAQCGGWabF/BONswq +CWUazVR9cG7uXm3NHp2jIr1p40CLC7scDCyeprZ5d+PQS4j/S1Ema++Ih8CQbCjG +BJjD5lf2OhhJdt6hfOkcUBzkJZf8aOAsS6zctRqyHCUtwxuLhFZpM4AkUfjuuZ3u +lcawv5YBkpG/hltE0fV+Jop0bWtpwiKxVsHXVcS0WEPXic0lsOTBCw8m81JXqjir +PCBOnkxgNpHSt69S1xnW3l9fPUWVlduO3EIZ5PZG2BxU081eZW31yIlKsDJhfgm6 +R5Vlr5DynqeojAd6SNliCzNXZP28GOpQBrYIeVQWA1yMANvkvd4apz9GmDrjF/Fd +g8Chah+5AoGBAPc/+zyuDZKVHK7MxwLPlchCm5Zb4eou4ycbwEB+P3gDS7MODGu4 +qvx7cstTZMuMavNRcJsfoiMMrke9JrqGe4rFGiKRFLVBY2Xwr+95pKNC11EWI1lF +5qDAmreDsj2alVJT5yZ9hsAWTsk2i+xj+/XHWYVkr67pRvOPRAmGMB+NAoGBAOb4 +CBHe184Hn6Ie+gSD4OjewyUVmr3JDJ41s8cjb1kBvDJ/wv9Rvo9yz2imMr2F0YGc +ytHraM77v8KOJuJWpvGjEg8I0a/rSttxWQ+J0oYJSIPn+eDpAijNWfOp1aKRNALT +pboCXcnSn+djJFKkNJ2hR7R/vrrM6Jyly1jcVS0zAoGAQpdt4Cr0pt0YS5AFraEh +Mz2VUArRLtSQA3F69yPJjlY85i3LdJvZGYVaJp8AT74y8/OkQ3NipNP+gH3WV3hu +/7IUVukCTcsdrVAE4pe9mucevM0cmie0dOlLAlArCmJ/Axxr7jbyuvuHHrRdPT60 +lr6pQr8afh6AKIsWhQYqIeUCgYA+v9IJcN52hhGzjPDl+yJGggbIc3cn6pA4B2UB +TDo7F0KXAajrjrzT4iBBUS3l2Y5SxVNA9tDxsumlJNOhmGMgsOn+FapKPgWHWuMU +WqBMdAc0dvinRwakKS4wCcsVsJdN0UxsHap3Y3a3+XJr1VrKHIALpM0fmP31WQHG +8Y1eiwKBgF6AYXxo0FzZacAommZrAYoxFZT1u4/rE/uvJ2K9HYRxLOVKZe+89ki3 +D7AOmrxe/CAc/D+nNrtUIv3RFGfadfSBWzyLw36ekW76xPdJgqJsSz5XJ/FgzDW+ +WNC5oOtiPOMCymP75oKOjuZJZ2SPLRmiuO/qvI5uAzBHxRC1BKdt +-----END RSA PRIVATE KEY----- +''' + +PUBLIC_KEY = ''' +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3xMJfyl8TOdrsjDLSIod +sArJ/NnQB3ZdfbFC5onxATDfRLLACHFo3ye694doBKeSe1NFYbfXPvahl6ODX1a2 +3oQyoRQwlL+M99cLcdCa0gGuJXdbAaF6Em8E+7uSb3290mI+rZmjqyc7gMtKVWKL +4e5i2PerFFBoYkZ7E90KOp2t0ZADx2uqF4VTOfYLHG0cPgSw9/ptDStJqJVAOiRR +qbv0j0GOFMDYNcN0mDlnpryhQFbQiMqn4IJIURZUVBJujFSa45cJPvSmMb6NrzZ1 +crg5UN6/5Mu2mxQzAi21+vpgGL+EEuekUd7sRgEAjTHjLKzotLAGo7EGa8sL1vMS +FwIDAQAB +-----END PUBLIC KEY----- +''' + +AUDIENCE = 'openid-client-id' + +ISSUER = 'https://www.myoidcprovider.com' + + +hmac_token_backend = TokenBackend('HS256', SECRET) +rsa_token_backend = TokenBackend('RS256', PRIVATE_KEY, PUBLIC_KEY) +aud_iss_token_backend = TokenBackend('RS256', PRIVATE_KEY, PUBLIC_KEY, AUDIENCE, ISSUER) +payload = {'foo': 'bar'} + + +def test_init(): + # Should reject unknown algorithms + with pytest.raises(TokenBackendError): + TokenBackend('oienarst oieanrsto i', 'not_secret') + + TokenBackend('HS256', 'not_secret') + + +@patch.object(algorithms, 'has_crypto', new=False) +def test_init_fails_for_rs_algorithms_when_crypto_not_installed(): + with pytest.raises(TokenBackendError, match=r'You must have cryptography installed to use RS256.'): + TokenBackend('RS256', 'not_secret') + with pytest.raises(TokenBackendError, match=r'You must have cryptography installed to use RS384.'): + TokenBackend('RS384', 'not_secret') + with pytest.raises(TokenBackendError, match=r'You must have cryptography installed to use RS512.'): + TokenBackend('RS512', 'not_secret') + + +def test_encode_hmac(): + # Should return a JSON web token for the given payload + payload = {'exp': make_utc(datetime(year=2000, month=1, day=1))} + + hmac_token = hmac_token_backend.encode(payload) + + # Token could be one of two depending on header dict ordering + assert hmac_token in ( + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjk0NjY4NDgwMH0.NHpdD2X8ub4SE_MZLBedWa57FCpntGaN_r6f8kNKdUs', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk0NjY4NDgwMH0.jvxQgXCSDToR8uKoRJcMT-LmMJJn2-NM76nfSR2FOgs', + ) + + +def test_encode_rsa(): + # Should return a JSON web token for the given payload + payload = {'exp': make_utc(datetime(year=2000, month=1, day=1))} + + rsa_token = rsa_token_backend.encode(payload) + + # Token could be one of two depending on header dict ordering + assert rsa_token in ( + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJleHAiOjk0NjY4NDgwMH0.cuE6ocmdxVHXVrGufzn-_ZbXDV475TPPb5jtSacvJsnR3s3tLIm9yR7MF4vGcCAUGJn2hU_JgSOhs9sxntPPVjdwvC3-sxAwfUQ5AgUCEAF5XC7wTvGhmvufzhAgEG_DNsactCh79P8xRnc0iugtlGNyFp_YK582-ZrlfQEp-7C0L9BNG_JCS2J9DsGR7ojO2xGFkFfzezKRkrVTJMPLwpl0JAiZ0iqbQE-Tex7redCrGI388_mgh52GLsNlSIvW2gQMqCVMYndMuYx32Pd5tuToZmLUQ2PJ9RyAZ4fOMApTzoshItg4lGqtnt9CDYzypHULJZohJIPcxFVZZfHxhw', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk0NjY4NDgwMH0.pzHTOaVvKJMMkSqksGh-NdeEvQy8Thre3hBM3smUW5Sohtg77KnHpaUYjq30DyRmYQRmPSjEVprh1Yvic_-OeAXPW8WVsF-r4YdJuxWUpuZbIPwJ9E-cMfTZkDkOl18z1zOdlsLtsP2kXyAlptyy9QQsM7AxoqM6cyXoQ5TI0geWccgoahTy3cBtA6pmjm7H0nfeDGqpqYQBhtaFmRuIWn-_XtdN9C6NVmRCcZwyjH-rP3oEm6wtuKJEN25sVWlZm8YRQ-rj7A7SNqBB5tFK2anM_iv4rmBlIEkmr_f2s_WqMxn2EWUSNeqbqiwabR6CZUyJtKx1cPG0B2PqOTcZsg', + ) + + +def test_encode_aud_iss(): + # Should return a JSON web token for the given payload + original_payload = {'exp': make_utc(datetime(year=2000, month=1, day=1))} + payload = original_payload.copy() + + rsa_token = aud_iss_token_backend.encode(payload) + + # Assert that payload has not been mutated by the encode() function + assert payload == original_payload + + # Token could be one of 12 depending on header dict ordering + assert rsa_token in ( + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJleHAiOjk0NjY4NDgwMCwiYXVkIjoib3BlbmlkLWNsaWVudC1pZCIsImlzcyI6Imh0dHBzOi8vd3d3Lm15b2lkY3Byb3ZpZGVyLmNvbSJ9.kSz7KyUZgpKaeQHYSQlhsE-UFLG2zhBiJ2MFCIvhstA4lSIKj3U1fdP1OhEDg7X66EquRRIZrby6M7RncqCdsjRwKrEIaL74KgC4s5PDXa_HC6dtpi2GhXqaLz8YxfCPaNGZ_9q9rs4Z4O6WpwBLNmMQrTxNno9p0uT93Z2yKj5hGih8a9C_CSf_rKtsHW9AJShWGoKpR6qQFKVNP1GAwQOQ6IeEvZenq_LSEywnrfiWp4Y5UF7xi42wWx7_YPQtM9_Bp5sB-DbrKg_8t0zSc-OHeVDgH0TKqygGEea09W0QkmJcROkaEbxt2LxJg9OuSdXgudVytV8ewpgNtWNE4g', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJleHAiOjk0NjY4NDgwMCwiaXNzIjoiaHR0cHM6Ly93d3cubXlvaWRjcHJvdmlkZXIuY29tIiwiYXVkIjoib3BlbmlkLWNsaWVudC1pZCJ9.l-sJR5VKGKNrEn5W8sfO4tEpPq4oQ-Fm5ttQyqUkF6FRJHmCfS1TZIUSXieDLHmarnb4hdIGLr5y-EAbykiqYaTn8d25oT2_zIPlCYHt0DxxeuOliGad5l3AXbWee0qPoZL7YCV8FaSdv2EjtMDOEiJBG5yTkaqZlRmSkbfqu1_y2DRErv3X5LpfEHuKoum4jv5YpoCS6wAWDaWJ9cXMPQaGc4gXg4cuSQxb_EjiQ3QYyztHhG37gOu1J-r_rudaiiIk_VZQdYNfCcirp8isS0z2dcNij_0bELp_oOaipsF7uwzc6WfNGR7xP50X1a_K6EBZzVs0eXFxvl9b3C_d8A', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJvcGVuaWQtY2xpZW50LWlkIiwiZXhwIjo5NDY2ODQ4MDAsImlzcyI6Imh0dHBzOi8vd3d3Lm15b2lkY3Byb3ZpZGVyLmNvbSJ9.aTwQEIxSzhI5fN4ffQMzZ6h61Ur-Gzh_gPkgOyyWvMX890Z18tC2RisEjXeL5xDGKe2XiEAVtuJa9CjXB9eJoCxNN1k05C-ph82cco-0m_TbMbs0d1MFnfC9ESr4JKynP_Klxi8bi0iZMazduT15pH4UhRkEGsnp8rOKtlt_8_8UOGBJAzG34161lM4JbZqrZDit1DvQdGxaC0lmMgosKg3NDMECfkPe3pGLJ5F_su5yhQk0xyKNCjYyE2FNlilWoDV2KkGiCWdsFOgRMAJwHZ-cdgPg8Vyh2WthBNblsbRVfDrGtfPX0VuW5B0RhBhvI5Iut34P9kkxKRFo3NaiEg', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJvcGVuaWQtY2xpZW50LWlkIiwiaXNzIjoiaHR0cHM6Ly93d3cubXlvaWRjcHJvdmlkZXIuY29tIiwiZXhwIjo5NDY2ODQ4MDB9.w46s7u28LgsnmK6K_5O15q1SFkKeRgkkplFLi5idq1z7qJjXUi45qpXIyQw3W8a0k1fwa22WB_0XC1MTo22OK3Z0aGNCI2ZCBxvZGOAc1WcCUae44a9LckPHp80q0Hs03NvjsuRVLGXRwDVHrYxuGnFxQSEMbZ650-MQkfVFIXVzMOOAn5Yl4ntjigLcw8iPEqJPnDLdFUSnObZjRzK1M6mJf0-125pqcFsCJaa49rjdbTtnN-VuGnKmv9wV1GwevRQPWjx2vinETURVO9IyZCDtdaLJkvL7Z5IpToK5jrZPc1UWAR0VR8WeWfussFoHzJF86LxVxnqIeXnqOhq5SQ', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3d3dy5teW9pZGNwcm92aWRlci5jb20iLCJhdWQiOiJvcGVuaWQtY2xpZW50LWlkIiwiZXhwIjo5NDY2ODQ4MDB9.Np_SJYye14vz0cvALvfYNqZXvXMD_gY6kIaxA458kbeu6veC_Ds45uWgjJFhTSYFAFWd3TB6M7qZbWgiO0ycION2-B9Yfgaf82WzNtPfgDhu51w1cbLnvuOSRvgX69Q6Z2i1SByewKaSDw25BaMv9Ty4DBdoCbG62qELnNKjDSQvuHlz8cRJv2I6xJBeOYgZV-YN8Zmxsles44a57Vvcj-DjVouHj5m4LperIxb9islNUQBPRTbvw1d_tR8O8ny0mQqbsWL7e2J-wfwdduVf1kVCPePkhHMM6GLhPIrSoTgMzZuRYLBJ61yphuDK98zTknNBM-Jtn5cMyBwP9JBJvA', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3d3dy5teW9pZGNwcm92aWRlci5jb20iLCJleHAiOjk0NjY4NDgwMCwiYXVkIjoib3BlbmlkLWNsaWVudC1pZCJ9.KJcWZtEluPrkRbYj2i_QmdWpZqGZt63Q8nO4QAJ4B4418ZfmgB6A54_vUmWd3Xc18DYgReLoNPlaOuRXtR7rzlMk0-ADjV0bsca5NwTNAV2F-gR9Xsr9iFlcPMNAYf4CAs85gg7deMIxlbGTAaoft__58ah2_vdd8o_nem1PdzsPC198AYtcwnIV206qpeCNR8S_ZTU46OaHwCoySVRx9E7tNG13YSCmGvBaEqgQHKH82qLXo0KivFrjGmGP0xePFG1B8HCZl-LH1euXGGCp6S48q-tmepX5GJwvzaZNBam4pfxQ0GIHa7z11e7sEN-196iDPCK8NzDcXFwHOAnyaA', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk0NjY4NDgwMCwiYXVkIjoib3BlbmlkLWNsaWVudC1pZCIsImlzcyI6Imh0dHBzOi8vd3d3Lm15b2lkY3Byb3ZpZGVyLmNvbSJ9.MfhVcFN-9Rd0j11CLtxopzREKdyJH1loSpD4ibjP6Aco4-iM5C99B6gliPgOldtuevhneXV2I7NGhmZFULaYhulcLrAgKe3Gj_TK-sHvwb62e14ArugmK_FAhN7UqbX8hU9wP42LaWXqA7ps4kkJSx-sfgHqMzCKewlAZWwyZBoFgWEgoheKZ7ILkSGf0jzBZlS_1R0jFRSrlYD9rI1S4Px-HllS0t32wRCSbzkp6aVMRs46S0ePrN1mK3spsuQXtYhE2913ZC7p2KwyTGfC2FOOeJdRJknh6kI3Z7pTcsjN2jnQN3o8vPEkN3wl7MbAgAsHcUV45pvyxn4SNBmTMQ', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk0NjY4NDgwMCwiaXNzIjoiaHR0cHM6Ly93d3cubXlvaWRjcHJvdmlkZXIuY29tIiwiYXVkIjoib3BlbmlkLWNsaWVudC1pZCJ9.3NjgS14SGFyJ6cix2XJZFPlcyeSu4LSduEMUIH0grJuCljbhalyoib5s4JnBaK4slKrQv1WHlhnKB737hX1FF7_EwQM3toCf--DBjrIuq5cYK3rzcn71JDe_op3CvClwpVyxd2vQZtQfP_tWqN6cNTuWF8ZQ0rJGug6Zb-NeE5h68YK_9tXLZC_i5anyjjAVONOc3Nd-TeIUBaLQKQXOddw0gcTcA7vg3uS0gXTEDq-_ZkF-v9bn1ua4_lgRPbuaYvrBFbXSCEdvNORPfPz4zfL3XU09q0gmnmXC9nxjUFVX4BjkP_YiCCO42sqUKY4y7STTB_IkK_04e2wntonVZA', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3d3dy5teW9pZGNwcm92aWRlci5jb20iLCJleHAiOjk0NjY4NDgwMCwiYXVkIjoib3BlbmlkLWNsaWVudC1pZCJ9.b4pdohov81oqzLyCIp4y7e4VYz7LSez7bH0t1o0Zwzau1uXPYXcasT9lxxNMEEiZwHIovPLyWQ6XvF0bMWTk9vc4PyIpkLqsLBJPsuQ-wYUOD04fECmqUX_JaINqm2pPbohTcOQwl0xsE1UMIKTFBZDL1hEXGEMdW9lrPcXilhbC1ikyMpzsmVh55Q_wL2GqydssnOOcDQTqEkWoKvELJJhBcE-YuQkUp8jEVhF3VZ4jEZkzCErTlyXcfe1qXZHkWtw2QNo9s_SfLuRy_fACOo8XE9pHBoE7rqiSm-FmISgiLO1Jj3Pqq-abjN4SnAbU7PZWcV3fUoO1eYLGORmAcw', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3d3dy5teW9pZGNwcm92aWRlci5jb20iLCJhdWQiOiJvcGVuaWQtY2xpZW50LWlkIiwiZXhwIjo5NDY2ODQ4MDB9.yDGMBeee4hj8yDtEvVtIsS4tnkPjDMQADTkNh74wtb3oYPgQNqMRWKngXiwjvW2FmnsCUue2cFzLgTbpqlDq0QKcBP0i_UwBiXk9m2wLo0WRFtgw2zNHYSsowu26sFoEjKLgpPZzKrPlU4pnxqa8u3yqg8vIcSTlpX8t3uDqNqhUKP6x-w6wb25h67XDmnORiMwhaOZE_Gs9-H6uWnKdguTIlU1Tj4CjUEnZgZN7dJORiDnO_vHiAyL5yvRjhp5YK0Pq-TtCj5kWoJsjQiKc4laIcgofAKoq_b62Psns8MhxzAxwM7i0rbQZXXYB0VKMUho88uHlpbSWCZxu415lWw', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJvcGVuaWQtY2xpZW50LWlkIiwiaXNzIjoiaHR0cHM6Ly93d3cubXlvaWRjcHJvdmlkZXIuY29tIiwiZXhwIjo5NDY2ODQ4MDB9.BHSCjFeXS6B7KFJi1LpQMEd3ib4Bp9FY3WcB-v7dtP3Ay0SxQZz_gxIbi-tYiNCBQIlfKcfq6vELOjE1WJ5zxPDQM8uV0Pjl41hqYBu3qFv4649a-o2Cd-MaSPUSUogPxzTh2Bk4IdM3sG1Zbd_At4DR_DQwWJDuChA8duA5yG2XPkZr0YF1ZJ326O_jEowvCJiZpzOpH9QsLVPbiX49jtWTwqQGhvpKEj3ztTLFo8VHO-p8bhOGEph2F73F6-GB0XqiWk2Dm1yKAunJCMsM4qXooWfaX6gj-WFhPI9kEXNFfGmPal5i1gb17YoeinbdV2kjN42oxast2Iaa3CMldw', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJvcGVuaWQtY2xpZW50LWlkIiwiZXhwIjo5NDY2ODQ4MDAsImlzcyI6Imh0dHBzOi8vd3d3Lm15b2lkY3Byb3ZpZGVyLmNvbSJ9.s6sElpfKL8WHWfbD_Kbwiy_ip4O082V8ElZqwugvDpS-7yQ3FTvQ3WXqtVAJc-fBZe4ZsBnrXUWwZV0Nhoe6iWKjEjTPjonQWbWL_WUJmIC2HVz18AOISnqReV2rcuLSHQ2ckhsyktlE9K1Rfj-Hi6f3HzzzePEgTsL2ZdBH6GrcmJVDFKqLLrkvOHShoPp7rcwuFBr0J_S1oqYac5O0B-u0OVnxBXTwij0ThrTGMgVCp2rn6Hk0NvtF6CE49Eu4XP8Ue-APT8l5_SjqX9GcrjkJp8Gif_oyBheL-zRg_v-cU60X6qY9wVolO8WodVPSnlE02XyYhLVxvfK-w5129A', + ) + + +def test_decode_hmac_with_no_expiry(): + no_exp_token = jwt.encode(payload, SECRET, algorithm='HS256') + + hmac_token_backend.decode(no_exp_token) + + +def test_decode_hmac_with_no_expiry_no_verify(): + no_exp_token = jwt.encode(payload, SECRET, algorithm='HS256') + + assert hmac_token_backend.decode(no_exp_token, verify=False) == payload + + +def test_decode_hmac_with_expiry(): + payload['exp'] = aware_utcnow() - timedelta(seconds=1) + + expired_token = jwt.encode(payload, SECRET, algorithm='HS256') + + with pytest.raises(TokenBackendError): + hmac_token_backend.decode(expired_token) + + +def test_decode_hmac_with_invalid_sig(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + token_1 = jwt.encode(payload, SECRET, algorithm='HS256') + payload['foo'] = 'baz' + token_2 = jwt.encode(payload, SECRET, algorithm='HS256') + + token_2_payload = token_2.rsplit('.', 1)[0] + token_1_sig = token_1.rsplit('.', 1)[-1] + invalid_token = token_2_payload + '.' + token_1_sig + + with pytest.raises(TokenBackendError): + hmac_token_backend.decode(invalid_token) + + +def test_decode_hmac_with_invalid_sig_no_verify(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + token_1 = jwt.encode(payload, SECRET, algorithm='HS256') + payload['foo'] = 'baz' + token_2 = jwt.encode(payload, SECRET, algorithm='HS256') + # Payload copied + payload["exp"] = datetime_to_epoch(payload["exp"]) + + token_2_payload = token_2.rsplit('.', 1)[0] + token_1_sig = token_1.rsplit('.', 1)[-1] + invalid_token = token_2_payload + '.' + token_1_sig + + assert hmac_token_backend.decode(invalid_token, verify=False) == payload + + +def test_decode_hmac_success(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + payload['foo'] = 'baz' + + token = jwt.encode(payload, SECRET, algorithm='HS256') + # Payload copied + payload["exp"] = datetime_to_epoch(payload["exp"]) + + assert hmac_token_backend.decode(token) == payload + + +def test_decode_rsa_with_no_expiry(): + no_exp_token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + rsa_token_backend.decode(no_exp_token) + + +def test_decode_rsa_with_no_expiry_no_verify(): + no_exp_token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + assert hmac_token_backend.decode(no_exp_token, verify=False) == payload + + +def test_decode_rsa_with_expiry(): + payload['exp'] = aware_utcnow() - timedelta(seconds=1) + + expired_token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + with pytest.raises(TokenBackendError): + rsa_token_backend.decode(expired_token) + + +def test_decode_rsa_with_invalid_sig(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + payload['foo'] = 'baz' + token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + token_payload = token.rsplit('.', 1)[0] + token_sig = token.rsplit('.', 1)[-1] + invalid_token = token_payload + '.' + token_sig.replace("a", "A") + + with pytest.raises(TokenBackendError): + rsa_token_backend.decode(invalid_token) + + +def test_decode_rsa_with_invalid_sig_no_verify(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + payload['foo'] = 'baz' + token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + token_payload = token.rsplit('.', 1)[0] + token_sig = token.rsplit('.', 1)[-1] + invalid_token = token_payload + '.' + token_sig.replace("a", "A") + + # Payload copied + payload["exp"] = datetime_to_epoch(payload["exp"]) + + assert hmac_token_backend.decode(invalid_token, verify=False) == payload + + +def test_decode_rsa_success(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + payload['foo'] = 'baz' + + token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + # Payload copied + payload["exp"] = datetime_to_epoch(payload["exp"]) + + assert rsa_token_backend.decode(token) == payload + + +def test_decode_aud_iss_success(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + payload['foo'] = 'baz' + payload['aud'] = AUDIENCE + payload['iss'] = ISSUER + + token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + # Payload copied + payload["exp"] = datetime_to_epoch(payload["exp"]) + + assert aud_iss_token_backend.decode(token) == payload + + +def test_decode_when_algorithm_not_available(): + token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + pyjwt_without_rsa = PyJWS() + pyjwt_without_rsa.unregister_algorithm('RS256') + with patch.object(jwt, 'decode', new=pyjwt_without_rsa.decode): + with pytest.raises(TokenBackendError, match=r'Invalid algorithm specified'): + rsa_token_backend.decode(token) + + +def test_decode_when_token_algorithm_does_not_match(): + token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + with pytest.raises(TokenBackendError, match=r'Invalid algorithm specified'): + hmac_token_backend.decode(token) diff --git a/tests/unit/auth/test_token_denylist.py b/tests/unit/auth/test_token_denylist.py new file mode 100644 index 00000000..29589902 --- /dev/null +++ b/tests/unit/auth/test_token_denylist.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest +from django.core.management import call_command +from unittest.mock import patch + +from taiga.auth.exceptions import TokenError +from taiga.auth.settings import api_settings +from taiga.auth.token_denylist.models import ( + DenylistedToken, OutstandingToken, +) +from taiga.auth.tokens import ( + AccessToken, RefreshToken +) +from taiga.auth.utils import aware_utcnow, datetime_from_epoch + +from tests import factories as f + + +pytestmark = pytest.mark.django_db + + +def test_refresh_tokens_are_added_to_outstanding_list(): + user = f.UserFactory( + username='test_user', + password='test_password', + ) + token = RefreshToken.for_user(user) + + qs = OutstandingToken.objects.all() + outstanding_token = qs.first() + + assert qs.count() == 1 + assert outstanding_token.user == user + assert outstanding_token.jti == token['jti'] + assert outstanding_token.token == str(token) + assert outstanding_token.created_at == token.current_time + assert outstanding_token.expires_at == datetime_from_epoch(token['exp']) + + +def test_access_tokens_are_not_added_to_outstanding_list(): + user = f.UserFactory( + username='test_user', + password='test_password', + ) + AccessToken.for_user(user) + + qs = OutstandingToken.objects.all() + + assert qs.exists() == False + + +def test_token_will_not_validate_if_denylisted(): + user = f.UserFactory( + username='test_user', + password='test_password', + ) + token = RefreshToken.for_user(user) + outstanding_token = OutstandingToken.objects.first() + + # Should raise no exception + RefreshToken(str(token)) + + # Add token to denylist + DenylistedToken.objects.create(token=outstanding_token) + + with pytest.raises(TokenError) as e: + # Should raise exception + RefreshToken(str(token)) + assert 'denylisted' in e.exception.args[0] + + +def test_tokens_can_be_manually_denylisted(): + user = f.UserFactory( + username='test_user', + password='test_password', + ) + token = RefreshToken.for_user(user) + + # Should raise no exception + RefreshToken(str(token)) + + assert OutstandingToken.objects.count() == 1 + + # Add token to denylist + denylisted_token, created = token.denylist() + + # Should not add token to outstanding list if already present + assert OutstandingToken.objects.count() == 1 + + # Should return denylist record and boolean to indicate creation + assert denylisted_token.token.jti == token['jti'] + assert created == True + + with pytest.raises(TokenError) as e: + # Should raise exception + RefreshToken(str(token)) + assert 'denylisted' in e.exception.args[0] + + # If denylisted token already exists, indicate no creation through + # boolean + denylisted_token, created = token.denylist() + assert denylisted_token.token.jti == token['jti'] + assert created == False + + # Should add token to outstanding list if not already present + new_token = RefreshToken() + denylisted_token, created = new_token.denylist() + assert denylisted_token.token.jti == new_token['jti'] + assert created == True + + assert OutstandingToken.objects.count() == 2 + + +def test_flush_expired_tokens_should_delete_any_expired_tokens(): + user = f.UserFactory( + username='test_user', + password='test_password', + ) + # Make some tokens that won't expire soon + not_expired_1 = RefreshToken.for_user(user) + not_expired_2 = RefreshToken.for_user(user) + not_expired_3 = RefreshToken() + + # Denylist fresh tokens + not_expired_2.denylist() + not_expired_3.denylist() + + # Make tokens with fake exp time that will expire soon + fake_now = aware_utcnow() - api_settings.REFRESH_TOKEN_LIFETIME + + with patch('taiga.auth.tokens.aware_utcnow') as fake_aware_utcnow: + fake_aware_utcnow.return_value = fake_now + expired_1 = RefreshToken.for_user(user) + expired_2 = RefreshToken() + + # Denylist expired tokens + expired_1.denylist() + expired_2.denylist() + + # Make another token that won't expire soon + not_expired_4 = RefreshToken.for_user(user) + + # Should be certain number of outstanding tokens and denylisted + # tokens + assert OutstandingToken.objects.count() == 6 + assert DenylistedToken.objects.count() == 4 + + call_command('flushexpiredtokens') + + # Expired outstanding *and* denylisted tokens should be gone + assert OutstandingToken.objects.count() == 4 + assert DenylistedToken.objects.count() == 2 + + assert ( + [i.jti for i in OutstandingToken.objects.order_by('id')] == + [not_expired_1['jti'], not_expired_2['jti'], not_expired_3['jti'], not_expired_4['jti']] + ) + assert ( + [i.token.jti for i in DenylistedToken.objects.order_by('id')] == + [not_expired_2['jti'], not_expired_3['jti']] + ) diff --git a/tests/unit/auth/test_tokens.py b/tests/unit/auth/test_tokens.py new file mode 100644 index 00000000..3a1a5f1b --- /dev/null +++ b/tests/unit/auth/test_tokens.py @@ -0,0 +1,430 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest + +from datetime import datetime, timedelta +from unittest.mock import patch + +from jose import jwt + +from taiga.base import exceptions as exc +from taiga.auth.exceptions import TokenError +from taiga.auth.settings import api_settings +from taiga.auth.state import token_backend +from taiga.auth.tokens import ( + AccessToken, CancelToken, RefreshToken, Token, UntypedToken, +) +from taiga.auth.utils import ( + aware_utcnow, datetime_to_epoch, make_utc, +) + +from tests import factories as f + +from .utils import override_api_settings + + +########################################################## +## Token +########################################################## + +class MyToken(Token): + token_type = 'test' + lifetime = timedelta(days=1) + + +@pytest.fixture +def token(): + return MyToken() + + +def test_init_no_token_type_or_lifetime(): + class MyTestToken(Token): + pass + + with pytest.raises(TokenError): + MyTestToken() + + MyTestToken.token_type = 'test' + + with pytest.raises(TokenError): + MyTestToken() + + del MyTestToken.token_type + MyTestToken.lifetime = timedelta(days=1) + + with pytest.raises(TokenError): + MyTestToken() + + MyTestToken.token_type = 'test' + MyTestToken() + + +def test_init_no_token_given(): + now = make_utc(datetime(year=2000, month=1, day=1)) + + with patch('taiga.auth.tokens.aware_utcnow') as fake_aware_utcnow: + fake_aware_utcnow.return_value = now + t = MyToken() + + assert t.current_time == now + assert t.token is None + + assert len(t.payload) == 3 + assert t.payload['exp'] == datetime_to_epoch(now + MyToken.lifetime) + assert 'jti' in t.payload + assert t.payload[api_settings.TOKEN_TYPE_CLAIM] == MyToken.token_type + + +def test_init_token_given(): + # Test successful instantiation + original_now = aware_utcnow() + + with patch('taiga.auth.tokens.aware_utcnow') as fake_aware_utcnow: + fake_aware_utcnow.return_value = original_now + good_token = MyToken() + + good_token['some_value'] = 'arst' + encoded_good_token = str(good_token) + + now = aware_utcnow() + + # Create new token from encoded token + with patch('taiga.auth.tokens.aware_utcnow') as fake_aware_utcnow: + fake_aware_utcnow.return_value = now + # Should raise no exception + t = MyToken(encoded_good_token) + + # Should have expected properties + assert t.current_time == now + assert t.token == encoded_good_token + + assert len(t.payload) == 4 + assert t['some_value'] == 'arst' + assert t['exp'] == datetime_to_epoch(original_now + MyToken.lifetime) + assert t[api_settings.TOKEN_TYPE_CLAIM] == MyToken.token_type + assert 'jti' in t.payload + + +def test_init_bad_sig_token_given(): + # Test backend rejects encoded token (expired or bad signature) + payload = {'foo': 'bar'} + payload['exp'] = aware_utcnow() + timedelta(days=1) + token_1 = jwt.encode(payload, api_settings.SIGNING_KEY, algorithm='HS256') + payload['foo'] = 'baz' + token_2 = jwt.encode(payload, api_settings.SIGNING_KEY, algorithm='HS256') + + token_2_payload = token_2.rsplit('.', 1)[0] + token_1_sig = token_1.rsplit('.', 1)[-1] + invalid_token = token_2_payload + '.' + token_1_sig + + with pytest.raises(TokenError): + MyToken(invalid_token) + + +def test_init_bad_sig_token_given_no_verify(): + # Test backend rejects encoded token (expired or bad signature) + payload = {'foo': 'bar'} + payload['exp'] = aware_utcnow() + timedelta(days=1) + token_1 = jwt.encode(payload, api_settings.SIGNING_KEY, algorithm='HS256') + payload['foo'] = 'baz' + token_2 = jwt.encode(payload, api_settings.SIGNING_KEY, algorithm='HS256') + + token_2_payload = token_2.rsplit('.', 1)[0] + token_1_sig = token_1.rsplit('.', 1)[-1] + invalid_token = token_2_payload + '.' + token_1_sig + + t = MyToken(invalid_token, verify=False) + + assert t.payload == payload + + +def test_init_expired_token_given(): + t = MyToken() + t.set_exp(lifetime=-timedelta(seconds=1)) + + with pytest.raises(TokenError): + MyToken(str(t)) + + +def test_init_no_type_token_given(): + t = MyToken() + del t[api_settings.TOKEN_TYPE_CLAIM] + + with pytest.raises(TokenError): + MyToken(str(t)) + + +def test_init_wrong_type_token_given(): + t = MyToken() + t[api_settings.TOKEN_TYPE_CLAIM] = 'wrong_type' + + with pytest.raises(TokenError): + MyToken(str(t)) + + +def test_init_no_jti_token_given(): + t = MyToken() + del t['jti'] + + with pytest.raises(TokenError): + MyToken(str(t)) + + +def test_str(): + token = MyToken() + token.set_exp( + from_time=make_utc(datetime(year=2000, month=1, day=1)), + lifetime=timedelta(seconds=0), + ) + + # Delete all but one claim. We want our lives to be easy and for there + # to only be a couple of possible encodings. We're only testing that a + # payload is successfully encoded here, not that it has specific + # content. + del token[api_settings.TOKEN_TYPE_CLAIM] + del token['jti'] + + # Should encode the given token + encoded_token = str(token) + + # Token could be one of two depending on header dict ordering + assert encoded_token in ( + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjk0NjY4NDgwMH0.VKoOnMgmETawjDZwxrQaHG0xHdo6xBodFy6FXJzTVxs', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk0NjY4NDgwMH0.iqxxOHV63sjeqNR1GDxX3LPvMymfVB76sOIDqTbjAgk', + ) + + +def test_repr(token): + assert repr(token) == repr(token.payload) + + +def test_getitem(token): + assert token['exp'], token.payload['exp'] + + +def test_setitem(token): + token['test'] = 1234 + assert token.payload['test'], 1234 + + +def test_delitem(token): + token['test'] = 1234 + assert token.payload['test'], 1234 + + del token['test'] + assert 'test' not in token + + +def test_contains(token): + assert 'exp' in token + + +def test_get(token): + token['test'] = 1234 + + assert 1234 == token.get('test') + assert 1234 == token.get('test', 2345) + + assert token.get('does_not_exist') is None + assert 1234 == token.get('does_not_exist', 1234) + + +def test_set_jti(): + token = MyToken() + old_jti = token['jti'] + + token.set_jti() + + assert 'jti' in token + assert old_jti != token['jti'] + + +def test_set_exp(): + now = make_utc(datetime(year=2000, month=1, day=1)) + + token = MyToken() + token.current_time = now + + # By default, should add 'exp' claim to token using `self.current_time` + # and the TOKEN_LIFETIME setting + token.set_exp() + assert token['exp'] == datetime_to_epoch(now + MyToken.lifetime) + + # Should allow overriding of beginning time, lifetime, and claim name + token.set_exp(claim='refresh_exp', from_time=now, lifetime=timedelta(days=1)) + + assert 'refresh_exp' in token + assert token['refresh_exp'] == datetime_to_epoch(now + timedelta(days=1)) + + +def test_check_exp(): + token = MyToken() + + # Should raise an exception if no claim of given kind + with pytest.raises(TokenError): + token.check_exp('non_existent_claim') + + current_time = token.current_time + lifetime = timedelta(days=1) + exp = token.current_time + lifetime + + token.set_exp(lifetime=lifetime) + + # By default, checks 'exp' claim against `self.current_time`. Should + # raise an exception if claim has expired. + token.current_time = exp + with pytest.raises(TokenError): + token.check_exp() + + token.current_time = exp + timedelta(seconds=1) + with pytest.raises(TokenError): + token.check_exp() + + # Otherwise, should raise no exception + token.current_time = current_time + token.check_exp() + + # Should allow specification of claim to be examined and timestamp to + # compare against + + # Default claim + with pytest.raises(TokenError): + token.check_exp(current_time=exp) + + token.set_exp('refresh_exp', lifetime=timedelta(days=1)) + + # Default timestamp + token.check_exp('refresh_exp') + + # Given claim and timestamp + with pytest.raises(TokenError): + token.check_exp('refresh_exp', current_time=current_time + timedelta(days=1)) + with pytest.raises(TokenError): + token.check_exp('refresh_exp', current_time=current_time + timedelta(days=2)) + + +@pytest.mark.django_db +def test_for_user(): + username = 'test_user' + user = f.UserFactory(username=username) + + token = MyToken.for_user(user) + + user_id = getattr(user, api_settings.USER_ID_FIELD) + if not isinstance(user_id, int): + user_id = str(user_id) + + assert token[api_settings.USER_ID_CLAIM] == user_id + + # Test with non-int user id + with override_api_settings(USER_ID_FIELD='username'): + token = MyToken.for_user(user) + + assert token[api_settings.USER_ID_CLAIM] == username + + +def test_get_token_backend(): + token = MyToken() + + assert token.get_token_backend() == token_backend + + +########################################################## +## AccessToken +########################################################## + +def test_access_token_init(): + # Should set token type claim + token = AccessToken() + assert token[api_settings.TOKEN_TYPE_CLAIM] == 'access' + + +########################################################## +## RefreshToken +########################################################## + +def test_refresh_token_init(): + # Should set token type claim + token = RefreshToken() + assert token[api_settings.TOKEN_TYPE_CLAIM] == 'refresh' + + +def test_refresh_token_access_token(): + # Should create an access token from a refresh token + refresh = RefreshToken() + refresh['test_claim'] = 'arst' + + access = refresh.access_token + + assert isinstance(access, AccessToken) + assert access[api_settings.TOKEN_TYPE_CLAIM] == 'access' + + # Should keep all copyable claims from refresh token + assert refresh['test_claim'] == access['test_claim'] + + # Should not copy certain claims from refresh token + for claim in RefreshToken.no_copy_claims: + assert refresh[claim] != access[claim] + + +########################################################## +## CancelToken +########################################################## + +def test_cancel_token_init(): + # Should set token type claim + token = CancelToken() + assert token[api_settings.TOKEN_TYPE_CLAIM] == 'cancel_account' + + +########################################################## +## UntypedToken +########################################################## + +def test_untyped_token_it_should_accept_and_verify_any_type_of_token(): + access_token = AccessToken() + refresh_token = RefreshToken() + cancel_token = CancelToken() + + for t in (access_token, refresh_token, cancel_token): + untyped_token = UntypedToken(str(t)) + + assert t.payload == untyped_token.payload + + +def test_untyped_token_it_should_expire_immediately_if_made_from_scratch(): + t = UntypedToken() + + assert t[api_settings.TOKEN_TYPE_CLAIM] == 'untyped' + + with pytest.raises(TokenError): + t.check_exp() diff --git a/tests/unit/auth/utils.py b/tests/unit/auth/utils.py new file mode 100644 index 00000000..a89c1da2 --- /dev/null +++ b/tests/unit/auth/utils.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos Ventures SL +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import contextlib + +from django.conf import settings +from django.test.client import RequestFactory as DjangoRequestFactory +from django.utils.encoding import force_bytes +from django.utils.http import urlencode + +from taiga.auth.settings import api_settings +from taiga.base.api import renderers + + +@contextlib.contextmanager +def override_api_settings(**settings): + old_settings = {} + + for k, v in settings.items(): + # Save settings + try: + old_settings[k] = api_settings.user_settings[k] + except KeyError: + pass + + # Install temporary settings + api_settings.user_settings[k] = v + + # Delete any cached settings + try: + delattr(api_settings, k) + except AttributeError: + pass + + yield + + for k in settings.keys(): + # Delete temporary settings + api_settings.user_settings.pop(k) + + # Restore saved settings + try: + api_settings.user_settings[k] = old_settings[k] + except KeyError: + pass + + # Delete any cached settings + try: + delattr(api_settings, k) + except AttributeError: + pass + + +class APIRequestFactory(DjangoRequestFactory): + renderer_classes_list = [ + renderers.MultiPartRenderer, + renderers.JSONRenderer + ] + default_format = "multipart" + + def __init__(self, enforce_csrf_checks=False, **defaults): + self.enforce_csrf_checks = enforce_csrf_checks + self.renderer_classes = {} + for cls in self.renderer_classes_list: + self.renderer_classes[cls.format] = cls + super().__init__(**defaults) + + def _encode_data(self, data, format=None, content_type=None): + """ + Encode the data returning a two tuple of (bytes, content_type) + """ + + if data is None: + return ('', content_type) + + assert format is None or content_type is None, ( + 'You may not set both `format` and `content_type`.' + ) + + if content_type: + # Content type specified explicitly, treat data as a raw bytestring + ret = force_bytes(data, settings.DEFAULT_CHARSET) + + else: + format = format or self.default_format + + assert format in self.renderer_classes, ( + "Invalid format '{}'. Available formats are {}. " + "Set TEST_REQUEST_RENDERER_CLASSES to enable " + "extra request formats.".format( + format, + ', '.join(["'" + fmt + "'" for fmt in self.renderer_classes]) + ) + ) + + # Use format and render the data into a bytestring + renderer = self.renderer_classes[format]() + ret = renderer.render(data) + + # Determine the content-type header from the renderer + content_type = renderer.media_type + if renderer.charset: + content_type = "{}; charset={}".format( + content_type, renderer.charset + ) + + # Coerce text to bytes if required. + if isinstance(ret, str): + ret = ret.encode(renderer.charset) + + return ret, content_type + + def get(self, path, data=None, **extra): + r = { + 'QUERY_STRING': urlencode(data or {}, doseq=True), + } + if not data and '?' in path: + # Fix to support old behavior where you have the arguments in the + # url. See #1461. + query_string = force_bytes(path.split('?')[1]) + query_string = query_string.decode('iso-8859-1') + r['QUERY_STRING'] = query_string + r.update(extra) + return self.generic('GET', path, **r) + + def post(self, path, data=None, format=None, content_type=None, **extra): + data, content_type = self._encode_data(data, format, content_type) + return self.generic('POST', path, data, content_type, **extra) + + def put(self, path, data=None, format=None, content_type=None, **extra): + data, content_type = self._encode_data(data, format, content_type) + return self.generic('PUT', path, data, content_type, **extra) + + def patch(self, path, data=None, format=None, content_type=None, **extra): + data, content_type = self._encode_data(data, format, content_type) + return self.generic('PATCH', path, data, content_type, **extra) + + def delete(self, path, data=None, format=None, content_type=None, **extra): + data, content_type = self._encode_data(data, format, content_type) + return self.generic('DELETE', path, data, content_type, **extra) + + def options(self, path, data=None, format=None, content_type=None, **extra): + data, content_type = self._encode_data(data, format, content_type) + return self.generic('OPTIONS', path, data, content_type, **extra) + + def generic(self, method, path, data='', + content_type='application/octet-stream', secure=False, **extra): + # Include the CONTENT_TYPE, regardless of whether or not data is empty. + if content_type is not None: + extra['CONTENT_TYPE'] = str(content_type) + + return super().generic( + method, path, data, content_type, secure, **extra) + + def request(self, **kwargs): + request = super().request(**kwargs) + request._dont_enforce_csrf_checks = not self.enforce_csrf_checks + return request + diff --git a/tests/unit/test_auth_services.py b/tests/unit/test_auth_services.py deleted file mode 100644 index 390f23f2..00000000 --- a/tests/unit/test_auth_services.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# -# Copyright (c) 2021-present Kaleidos Ventures SL - -import pytest -from unittest.mock import patch -from taiga.auth import services -from taiga.base import exceptions as exc -from taiga.base.api.request import Request -from django.http import HttpRequest, QueryDict - - -@patch("taiga.auth.services.make_auth_response_data") -@patch("taiga.auth.services.get_and_validate_user") -def test_normal_login_func_transforms_input_into_str(get_and_validate_user_mock, make_auth_response_data_mock): - http_request = HttpRequest() - request = Request(http_request) - request._data = QueryDict(mutable=True) - request._data["username"] = {"username": "myusername"} - request._data["password"] = 123 - - services.normal_login_func(request) - - get_and_validate_user_mock.assert_called_once_with( - username="{'username': 'myusername'}", - password="123", - ) - make_auth_response_data_mock.assert_called() diff --git a/tests/unit/test_export.py b/tests/unit/test_export.py index c45eb611..d34a7211 100644 --- a/tests/unit/test_export.py +++ b/tests/unit/test_export.py @@ -7,14 +7,26 @@ import pytest import io -from .. import factories as f from taiga.base.utils import json from taiga.export_import.services import render_project +from tests.utils import disconnect_signals, reconnect_signals + +from .. import factories as f + + pytestmark = pytest.mark.django_db(transaction=True) +def setup_module(): + disconnect_signals() + + +def teardown_module(): + reconnect_signals() + + def test_export_issue_finish_date(client): issue = f.IssueFactory.create(finished_date="2014-10-22T00:00:00+0000") output = io.BytesIO() diff --git a/tests/unit/test_slug.py b/tests/unit/test_slug.py index a1fda164..f0f01c93 100644 --- a/tests/unit/test_slug.py +++ b/tests/unit/test_slug.py @@ -5,13 +5,22 @@ # # Copyright (c) 2021-present Kaleidos Ventures SL +import pytest + from django.contrib.auth import get_user_model from taiga.projects.models import Project from taiga.base.utils.slug import slugify -import pytest -pytestmark = pytest.mark.django_db(transaction=True) +from tests.utils import disconnect_signals, reconnect_signals + + +def setup_module(): + disconnect_signals() + + +def teardown_module(): + reconnect_signals() def test_slugify_1(): @@ -26,6 +35,7 @@ def test_slugify_3(): assert slugify(None) == "" +@pytest.mark.django_db def test_project_slug_with_special_chars(): user = get_user_model().objects.create(username="test") project = Project.objects.create(name="漢字", description="漢字", owner=user) @@ -34,6 +44,7 @@ def test_project_slug_with_special_chars(): assert project.slug == "test-han-zi" +@pytest.mark.django_db def test_project_with_existing_name_slug_with_special_chars(): user = get_user_model().objects.create(username="test") Project.objects.create(name="漢字", description="漢字", owner=user) diff --git a/tests/unit/test_tokens.py b/tests/unit/test_tokens.py deleted file mode 100644 index 4b6fda85..00000000 --- a/tests/unit/test_tokens.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# -# Copyright (c) 2021-present Kaleidos Ventures SL - -import pytest - -from .. import factories as f - -from taiga.base import exceptions as exc -from taiga.auth.tokens import get_token_for_user, get_user_for_token - - -pytestmark = pytest.mark.django_db(transaction=True) - - -def test_valid_token(): - user = f.UserFactory.create(email="old@email.com") - token = get_token_for_user(user, "testing_scope") - user_from_token = get_user_for_token(token, "testing_scope") - assert user.id == user_from_token.id - - -@pytest.mark.xfail(raises=exc.NotAuthenticated) -def test_invalid_token(): - f.UserFactory.create(email="old@email.com") - get_user_for_token("testing_invalid_token", "testing_scope") - - -@pytest.mark.xfail(raises=exc.NotAuthenticated) -def test_invalid_token_expiration(): - user = f.UserFactory.create(email="old@email.com") - token = get_token_for_user(user, "testing_scope") - get_user_for_token(token, "testing_scope", max_age=1) - - -@pytest.mark.xfail(raises=exc.NotAuthenticated) -def test_invalid_token_scope(): - user = f.UserFactory.create(email="old@email.com") - token = get_token_for_user(user, "testing_scope") - get_user_for_token(token, "testing_invalid_scope")