1
0
mirror of https://github.com/taigaio/taiga-back synced 2025-10-05 15:52:48 +02:00

feat(auth): new auth module (tg-4625, tgg-626)

This commit is contained in:
David Barragán Merino
2021-07-14 11:54:53 +02:00
committed by David Barragán Merino
parent 1968c6e1c8
commit 0be90e6a66
54 changed files with 3757 additions and 471 deletions

View File

@@ -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)

View File

@@ -4,3 +4,4 @@ coveralls
pytest
pytest-django
factory-boy
python-jose>=3.0.0

View File

@@ -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

View File

@@ -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",

View File

@@ -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.

View File

@@ -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"))

View File

@@ -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)

View File

@@ -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'))

84
taiga/auth/compat.py Normal file
View File

@@ -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)

70
taiga/auth/exceptions.py Normal file
View File

@@ -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'

136
taiga/auth/models.py Normal file
View File

@@ -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

View File

@@ -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()

105
taiga/auth/serializers.py Normal file
View File

@@ -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)

View File

@@ -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)

304
taiga/auth/settings.py Normal file
View File

@@ -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 <token>``. 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)

36
taiga/auth/state.py Normal file
View File

@@ -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)

View File

@@ -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)

View File

@@ -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'

View File

@@ -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)

View File

@@ -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'

View File

@@ -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()

View File

@@ -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,
},
),
]

View File

@@ -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)

View File

@@ -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')

View File

@@ -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

63
taiga/auth/utils.py Normal file
View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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': (),
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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())

View File

@@ -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

View File

@@ -88,7 +88,7 @@ class ChangeEmailValidator(validators.Validator):
class CancelAccountValidator(validators.Validator):
cancel_token = serializers.CharField(max_length=200)
cancel_token = serializers.CharField()
######################################################

View File

@@ -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

View File

@@ -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')

View File

@@ -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 <script>evil()</script> 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 <script>evil()</script> 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

View File

@@ -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)

View File

@@ -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")

View File

@@ -5,8 +5,3 @@
#
# Copyright (c) 2021-present Kaleidos Ventures SL
from ..utils import disconnect_signals
def pytest_runtest_setup(item):
disconnect_signals()

View File

@@ -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

View File

@@ -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)

View File

@@ -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']]
)

View File

@@ -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()

187
tests/unit/auth/utils.py Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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")