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:
committed by
David Barragán Merino
parent
1968c6e1c8
commit
0be90e6a66
@@ -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)
|
||||
|
||||
|
@@ -4,3 +4,4 @@ coveralls
|
||||
pytest
|
||||
pytest-django
|
||||
factory-boy
|
||||
python-jose>=3.0.0
|
||||
|
@@ -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
|
||||
|
@@ -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",
|
||||
|
@@ -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.
|
||||
|
@@ -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"))
|
||||
|
166
taiga/auth/authentication.py
Normal file
166
taiga/auth/authentication.py
Normal 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)
|
@@ -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
84
taiga/auth/compat.py
Normal 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
70
taiga/auth/exceptions.py
Normal 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
136
taiga/auth/models.py
Normal 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
|
@@ -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
105
taiga/auth/serializers.py
Normal 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)
|
@@ -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
304
taiga/auth/settings.py
Normal 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
36
taiga/auth/state.py
Normal 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)
|
@@ -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)
|
||||
|
||||
|
32
taiga/auth/token_denylist/__init__.py
Normal file
32
taiga/auth/token_denylist/__init__.py
Normal 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'
|
122
taiga/auth/token_denylist/admin.py
Normal file
122
taiga/auth/token_denylist/admin.py
Normal 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)
|
75
taiga/auth/token_denylist/apps.py
Normal file
75
taiga/auth/token_denylist/apps.py
Normal 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'
|
0
taiga/auth/token_denylist/management/__init__.py
Normal file
0
taiga/auth/token_denylist/management/__init__.py
Normal 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()
|
50
taiga/auth/token_denylist/migrations/0001_initial.py
Normal file
50
taiga/auth/token_denylist/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
0
taiga/auth/token_denylist/migrations/__init__.py
Normal file
0
taiga/auth/token_denylist/migrations/__init__.py
Normal file
77
taiga/auth/token_denylist/models.py
Normal file
77
taiga/auth/token_denylist/models.py
Normal 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)
|
17
taiga/auth/token_denylist/tasks.py
Normal file
17
taiga/auth/token_denylist/tasks.py
Normal 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')
|
@@ -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
63
taiga/auth/utils.py
Normal 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)
|
@@ -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)
|
@@ -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
|
||||
|
@@ -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': (),
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
||||
|
@@ -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:
|
||||
|
@@ -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())
|
||||
|
@@ -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
|
||||
|
@@ -88,7 +88,7 @@ class ChangeEmailValidator(validators.Validator):
|
||||
|
||||
|
||||
class CancelAccountValidator(validators.Validator):
|
||||
cancel_token = serializers.CharField(max_length=200)
|
||||
cancel_token = serializers.CharField()
|
||||
|
||||
|
||||
######################################################
|
||||
|
@@ -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
|
||||
|
@@ -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')
|
||||
|
@@ -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
|
||||
|
||||
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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")
|
||||
|
||||
|
@@ -5,8 +5,3 @@
|
||||
#
|
||||
# Copyright (c) 2021-present Kaleidos Ventures SL
|
||||
|
||||
from ..utils import disconnect_signals
|
||||
|
||||
|
||||
def pytest_runtest_setup(item):
|
||||
disconnect_signals()
|
222
tests/unit/auth/test_authentication.py
Normal file
222
tests/unit/auth/test_authentication.py
Normal 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
|
319
tests/unit/auth/test_backends.py
Normal file
319
tests/unit/auth/test_backends.py
Normal 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)
|
191
tests/unit/auth/test_token_denylist.py
Normal file
191
tests/unit/auth/test_token_denylist.py
Normal 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']]
|
||||
)
|
430
tests/unit/auth/test_tokens.py
Normal file
430
tests/unit/auth/test_tokens.py
Normal 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
187
tests/unit/auth/utils.py
Normal 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
|
||||
|
@@ -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()
|
@@ -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()
|
||||
|
@@ -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)
|
||||
|
@@ -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")
|
Reference in New Issue
Block a user