From e65c8da425dd241d445361482ab3c12c4b97b0a6 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 3 Oct 2024 13:34:02 +0100 Subject: [PATCH] feat: Support cookie authentication (#4662) Co-authored-by: Kyle Johnson --- api/Makefile | 2 +- api/app/settings/common.py | 45 ++-- api/app/views.py | 9 +- api/core/helpers.py | 20 +- api/custom_auth/apps.py | 1 + api/custom_auth/jwt_cookie/__init__.py | 0 api/custom_auth/jwt_cookie/authentication.py | 17 ++ api/custom_auth/jwt_cookie/constants.py | 1 + api/custom_auth/jwt_cookie/services.py | 18 ++ api/custom_auth/jwt_cookie/signals.py | 20 ++ api/custom_auth/jwt_cookie/views.py | 15 ++ api/custom_auth/serializers.py | 16 +- api/custom_auth/urls.py | 8 +- api/custom_auth/views.py | 21 ++ api/poetry.lock | 42 +++- api/pyproject.toml | 1 + .../test_custom_auth_integration.py | 192 ++++++++++++++++++ api/tests/unit/core/test_helpers.py | 37 +++- frontend/api/index.js | 1 + frontend/common/data/base/_data.js | 3 +- frontend/common/service.ts | 5 +- frontend/common/stores/account-store.js | 35 ++-- frontend/web/main.js | 6 +- frontend/webpack/plugins.js | 2 +- .../aws/staging/ecs-task-definition-web.json | 2 +- 25 files changed, 452 insertions(+), 67 deletions(-) create mode 100644 api/custom_auth/jwt_cookie/__init__.py create mode 100644 api/custom_auth/jwt_cookie/authentication.py create mode 100644 api/custom_auth/jwt_cookie/constants.py create mode 100644 api/custom_auth/jwt_cookie/services.py create mode 100644 api/custom_auth/jwt_cookie/signals.py create mode 100644 api/custom_auth/jwt_cookie/views.py diff --git a/api/Makefile b/api/Makefile index 947036d908b9..44863d3cf51b 100644 --- a/api/Makefile +++ b/api/Makefile @@ -11,7 +11,7 @@ POETRY_VERSION ?= 1.8.3 GUNICORN_LOGGER_CLASS ?= util.logging.GunicornJsonCapableLogger -SAML_REVISION ?= v1.6.3 +SAML_REVISION ?= v1.6.4 RBAC_REVISION ?= v0.8.0 -include .env-local diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 1a8067d80cfd..aafd4afea939 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -94,6 +94,7 @@ "rest_framework.authtoken", # Used for managing api keys "rest_framework_api_key", + "rest_framework_simplejwt.token_blacklist", "djoser", "django.contrib.sites", "custom_auth", @@ -254,6 +255,7 @@ REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], "DEFAULT_AUTHENTICATION_CLASSES": ( + "custom_auth.jwt_cookie.authentication.JWTCookieAuthentication", "rest_framework.authentication.TokenAuthentication", "api_keys.authentication.MasterAPIKeyAuthentication", ), @@ -416,19 +418,6 @@ MEDIA_URL = "/media/" # unused but needs to be different from STATIC_URL in django 3 -# CORS settings - -CORS_ORIGIN_ALLOW_ALL = True -FLAGSMITH_CORS_EXTRA_ALLOW_HEADERS = env.list( - "FLAGSMITH_CORS_EXTRA_ALLOW_HEADERS", default=["sentry-trace"] -) -CORS_ALLOW_HEADERS = [ - *default_headers, - *FLAGSMITH_CORS_EXTRA_ALLOW_HEADERS, - "X-Environment-Key", - "X-E2E-Test-Auth-Token", -] - DEFAULT_FROM_EMAIL = env("SENDER_EMAIL", default="noreply@flagsmith.com") EMAIL_CONFIGURATION = { # Invitations with name is anticipated to take two arguments. The persons name and the @@ -826,6 +815,16 @@ "user_create": USER_CREATE_PERMISSIONS, }, } +SIMPLE_JWT = { + "AUTH_TOKEN_CLASSES": ["rest_framework_simplejwt.tokens.SlidingToken"], + "SLIDING_TOKEN_LIFETIME": timedelta( + minutes=env.int( + "COOKIE_AUTH_JWT_ACCESS_TOKEN_LIFETIME_MINUTES", + default=10 * 60, + ) + ), + "SIGNING_KEY": env.str("COOKIE_AUTH_JWT_SIGNING_KEY", default=SECRET_KEY), +} # Github OAuth credentials GITHUB_CLIENT_ID = env.str("GITHUB_CLIENT_ID", default="") @@ -907,8 +906,6 @@ SENTRY_API_KEY = env("SENTRY_API_KEY", default=None) AMPLITUDE_API_KEY = env("AMPLITUDE_API_KEY", default=None) ENABLE_FLAGSMITH_REALTIME = env.bool("ENABLE_FLAGSMITH_REALTIME", default=False) -USE_SECURE_COOKIES = env.bool("USE_SECURE_COOKIES", default=True) -COOKIE_SAME_SITE = env.str("COOKIE_SAME_SITE", default="none") # Set this to enable create organisation for only superusers RESTRICT_ORG_CREATE_TO_SUPERUSERS = env.bool("RESTRICT_ORG_CREATE_TO_SUPERUSERS", False) @@ -1038,6 +1035,24 @@ DISABLE_INVITE_LINKS = env.bool("DISABLE_INVITE_LINKS", False) PREVENT_SIGNUP = env.bool("PREVENT_SIGNUP", default=False) +COOKIE_AUTH_ENABLED = env.bool("COOKIE_AUTH_ENABLED", default=False) +USE_SECURE_COOKIES = env.bool("USE_SECURE_COOKIES", default=True) +COOKIE_SAME_SITE = env.str("COOKIE_SAME_SITE", default="none") + +# CORS settings + +CORS_ORIGIN_ALLOW_ALL = env.bool("CORS_ORIGIN_ALLOW_ALL", not COOKIE_AUTH_ENABLED) +CORS_ALLOW_CREDENTIALS = env.bool("CORS_ALLOW_CREDENTIALS", COOKIE_AUTH_ENABLED) +FLAGSMITH_CORS_EXTRA_ALLOW_HEADERS = env.list( + "FLAGSMITH_CORS_EXTRA_ALLOW_HEADERS", default=["sentry-trace"] +) +CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[]) +CORS_ALLOW_HEADERS = [ + *default_headers, + *FLAGSMITH_CORS_EXTRA_ALLOW_HEADERS, + "X-Environment-Key", + "X-E2E-Test-Auth-Token", +] # use a separate boolean setting so that we add it to the API containers in environments # where we're running the task processor, so we avoid creating unnecessary tasks diff --git a/api/app/views.py b/api/app/views.py index ba2b9be9b4c4..830fec912438 100644 --- a/api/app/views.py +++ b/api/app/views.py @@ -37,14 +37,17 @@ def project_overrides(request): "amplitude": "AMPLITUDE_API_KEY", "api": "API_URL", "assetURL": "ASSET_URL", + "cookieAuthEnabled": "COOKIE_AUTH_ENABLED", + "cookieSameSite": "COOKIE_SAME_SITE", "crispChat": "CRISP_CHAT_API_KEY", "disableAnalytics": "DISABLE_ANALYTICS_FEATURES", "flagsmith": "FLAGSMITH_ON_FLAGSMITH_API_KEY", "flagsmithAnalytics": "FLAGSMITH_ANALYTICS", - "flagsmithRealtime": "ENABLE_FLAGSMITH_REALTIME", "flagsmithClientAPI": "FLAGSMITH_ON_FLAGSMITH_API_URL", - "ga": "GOOGLE_ANALYTICS_API_KEY", + "flagsmithRealtime": "ENABLE_FLAGSMITH_REALTIME", "fpr": "FIRST_PROMOTER_ID", + "ga": "GOOGLE_ANALYTICS_API_KEY", + "githubAppURL": "GITHUB_APP_URL", "headway": "HEADWAY_API_KEY", "hideInviteLinks": "DISABLE_INVITE_LINKS", "linkedinPartnerTracking": "LINKEDIN_PARTNER_TRACKING", @@ -54,8 +57,6 @@ def project_overrides(request): "preventSignup": "PREVENT_SIGNUP", "sentry": "SENTRY_API_KEY", "useSecureCookies": "USE_SECURE_COOKIES", - "cookieSameSite": "COOKIE_SAME_SITE", - "githubAppURL": "GITHUB_APP_URL", } override_data = { diff --git a/api/core/helpers.py b/api/core/helpers.py index 3af9a067664d..4f327e5a8556 100644 --- a/api/core/helpers.py +++ b/api/core/helpers.py @@ -1,7 +1,7 @@ import re from django.conf import settings -from django.contrib.sites.models import Site +from django.contrib.sites import models as sites_models from django.http import HttpRequest from rest_framework.request import Request @@ -11,12 +11,18 @@ def get_current_site_url(request: HttpRequest | Request | None = None) -> str: - if settings.DOMAIN_OVERRIDE: - domain = settings.DOMAIN_OVERRIDE - elif current_site := Site.objects.filter(id=settings.SITE_ID).first(): - domain = current_site.domain - else: - domain = settings.DEFAULT_DOMAIN + if not (domain := settings.DOMAIN_OVERRIDE): + try: + domain = sites_models.Site.objects.get_current(request).domain + except sites_models.Site.DoesNotExist: + # For the rare case when `DOMAIN_OVERRIDE` was not set and no `Site` object present, + # store a default domain `Site` in the sites cache + # so it's correctly invalidated should the user decide to create own `Site` object. + domain = settings.DEFAULT_DOMAIN + sites_models.SITE_CACHE[settings.SITE_ID] = sites_models.Site( + name="Flagsmith", + domain=domain, + ) if request: scheme = request.scheme diff --git a/api/custom_auth/apps.py b/api/custom_auth/apps.py index 2328a449d114..005927503540 100644 --- a/api/custom_auth/apps.py +++ b/api/custom_auth/apps.py @@ -6,3 +6,4 @@ class CustomAuthAppConfig(AppConfig): def ready(self) -> None: from custom_auth import tasks # noqa F401 + from custom_auth.jwt_cookie import signals # noqa F401 diff --git a/api/custom_auth/jwt_cookie/__init__.py b/api/custom_auth/jwt_cookie/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/custom_auth/jwt_cookie/authentication.py b/api/custom_auth/jwt_cookie/authentication.py new file mode 100644 index 000000000000..379617b1fa10 --- /dev/null +++ b/api/custom_auth/jwt_cookie/authentication.py @@ -0,0 +1,17 @@ +from rest_framework.request import Request +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework_simplejwt.tokens import Token + +from custom_auth.jwt_cookie.constants import JWT_SLIDING_COOKIE_KEY +from users.models import FFAdminUser + + +class JWTCookieAuthentication(JWTAuthentication): + def authenticate_header(self, request: Request) -> str: + return f'Cookie realm="{self.www_authenticate_realm}"' + + def authenticate(self, request: Request) -> tuple[FFAdminUser, Token] | None: + if raw_token := request.COOKIES.get(JWT_SLIDING_COOKIE_KEY): + validated_token = self.get_validated_token(raw_token) + return self.get_user(validated_token), validated_token + return None diff --git a/api/custom_auth/jwt_cookie/constants.py b/api/custom_auth/jwt_cookie/constants.py new file mode 100644 index 000000000000..340700b867b4 --- /dev/null +++ b/api/custom_auth/jwt_cookie/constants.py @@ -0,0 +1 @@ +JWT_SLIDING_COOKIE_KEY = "jwt" diff --git a/api/custom_auth/jwt_cookie/services.py b/api/custom_auth/jwt_cookie/services.py new file mode 100644 index 000000000000..eb7796309272 --- /dev/null +++ b/api/custom_auth/jwt_cookie/services.py @@ -0,0 +1,18 @@ +from django.conf import settings +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import SlidingToken + +from custom_auth.jwt_cookie.constants import JWT_SLIDING_COOKIE_KEY +from users.models import FFAdminUser + + +def authorise_response(user: FFAdminUser, response: Response) -> Response: + sliding_token = SlidingToken.for_user(user) + response.set_cookie( + JWT_SLIDING_COOKIE_KEY, + str(sliding_token), + httponly=True, + secure=settings.USE_SECURE_COOKIES, + samesite=settings.COOKIE_SAME_SITE, + ) + return response diff --git a/api/custom_auth/jwt_cookie/signals.py b/api/custom_auth/jwt_cookie/signals.py new file mode 100644 index 000000000000..1e840eb681d7 --- /dev/null +++ b/api/custom_auth/jwt_cookie/signals.py @@ -0,0 +1,20 @@ +from typing import Any +from urllib.parse import urlparse + +from core.helpers import get_current_site_url +from corsheaders.signals import check_request_enabled +from django.dispatch import receiver +from django.http import HttpRequest + + +@receiver(check_request_enabled) +def cors_allow_current_site(request: HttpRequest, **kwargs: Any) -> bool: + # The signal is expected to only be dispatched: + # - When `settings.CORS_ORIGIN_ALLOW_ALL` is set to `False`. + # - For requests with `HTTP_ORIGIN` set. + origin_url = urlparse(request.META["HTTP_ORIGIN"]) + current_site_url = urlparse(get_current_site_url(request)) + return ( + origin_url.scheme == current_site_url.scheme + and origin_url.netloc == current_site_url.netloc + ) diff --git a/api/custom_auth/jwt_cookie/views.py b/api/custom_auth/jwt_cookie/views.py new file mode 100644 index 000000000000..677df48ccaa4 --- /dev/null +++ b/api/custom_auth/jwt_cookie/views.py @@ -0,0 +1,15 @@ +from djoser.views import TokenDestroyView +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import SlidingToken + +from custom_auth.jwt_cookie.constants import JWT_SLIDING_COOKIE_KEY + + +class JWTSlidingTokenLogoutView(TokenDestroyView): + def post(self, request: Request) -> Response: + response = super().post(request) + if isinstance(jwt_token := request.auth, SlidingToken): + jwt_token.blacklist() + response.delete_cookie(JWT_SLIDING_COOKIE_KEY) + return response diff --git a/api/custom_auth/serializers.py b/api/custom_auth/serializers.py index c4176a86c9ca..d1322c1d50be 100644 --- a/api/custom_auth/serializers.py +++ b/api/custom_auth/serializers.py @@ -1,3 +1,5 @@ +from typing import Any + from django.conf import settings from djoser.conf import settings as djoser_settings from djoser.serializers import TokenCreateSerializer, UserCreateSerializer @@ -73,13 +75,15 @@ def _validate_registration_invite(self, email: str, sign_up_type: str) -> None: class CustomUserCreateSerializer(UserCreateSerializer, InviteLinkValidationMixin): - key = serializers.SerializerMethodField() + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + if not settings.COOKIE_AUTH_ENABLED: + self.fields["key"] = serializers.SerializerMethodField() class Meta(UserCreateSerializer.Meta): fields = UserCreateSerializer.Meta.fields + ( "is_active", "marketing_consent_given", - "key", "uuid", ) read_only_fields = ("is_active", "uuid") @@ -115,8 +119,14 @@ def validate(self, attrs): attrs["email"] = email.lower() return attrs + def save(self) -> FFAdminUser: + instance = super().save() + if "view" in self.context: + self.context["view"].user = instance + return instance + @staticmethod - def get_key(instance): + def get_key(instance) -> str: token, _ = Token.objects.get_or_create(user=instance) return token.key diff --git a/api/custom_auth/urls.py b/api/custom_auth/urls.py index 54995ad5def7..1494567fc698 100644 --- a/api/custom_auth/urls.py +++ b/api/custom_auth/urls.py @@ -1,7 +1,7 @@ from django.urls import include, path -from djoser.views import TokenDestroyView from rest_framework.routers import DefaultRouter +from custom_auth.jwt_cookie.views import JWTSlidingTokenLogoutView from custom_auth.views import ( CustomAuthTokenLoginOrRequestMFACode, CustomAuthTokenLoginWithMFACode, @@ -26,7 +26,11 @@ CustomAuthTokenLoginWithMFACode.as_view(), name="mfa-authtoken-login-code", ), - path("logout/", TokenDestroyView.as_view(), name="authtoken-logout"), + path( + "logout/", + JWTSlidingTokenLogoutView.as_view(), + name="jwt-logout", + ), path("", include(ffadmin_user_router.urls)), path("token/", delete_token, name="delete-token"), # NOTE: endpoints provided by `djoser.urls` diff --git a/api/custom_auth/views.py b/api/custom_auth/views.py index 97381120fd93..5cc5deedbf55 100644 --- a/api/custom_auth/views.py +++ b/api/custom_auth/views.py @@ -1,3 +1,5 @@ +from typing import Any + from django.conf import settings from django.contrib.auth import user_logged_out from django.utils.decorators import method_decorator @@ -9,8 +11,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.status import HTTP_204_NO_CONTENT from rest_framework.throttling import ScopedRateThrottle +from custom_auth.jwt_cookie.services import authorise_response from custom_auth.mfa.backends.application import CustomApplicationBackend from custom_auth.mfa.trench.command.authenticate_second_factor import ( authenticate_second_step_command, @@ -34,6 +38,7 @@ class CustomAuthTokenLoginOrRequestMFACode(TokenCreateView): Class to handle throttling for login requests """ + authentication_classes = [] throttle_classes = [ScopedRateThrottle] throttle_scope = "login" @@ -54,6 +59,8 @@ def post(self, request: Request) -> Response: } ) except MFAMethodDoesNotExistError: + if settings.COOKIE_AUTH_ENABLED: + return authorise_response(user, Response(status=HTTP_204_NO_CONTENT)) return self._action(serializer) @@ -62,6 +69,7 @@ class CustomAuthTokenLoginWithMFACode(TokenCreateView): Override class to add throttling """ + authentication_classes = [] throttle_classes = [ScopedRateThrottle] throttle_scope = "mfa_code" @@ -74,6 +82,8 @@ def post(self, request: Request) -> Response: ephemeral_token=serializer.validated_data["ephemeral_token"], ) serializer.user = user + if settings.COOKIE_AUTH_ENABLED: + return authorise_response(user, Response(status=HTTP_204_NO_CONTENT)) return self._action(serializer) except MFAValidationError as cause: return ErrorResponse(error=cause, status=status.HTTP_401_UNAUTHORIZED) @@ -96,6 +106,11 @@ def delete_token(request): class FFAdminUserViewSet(UserViewSet): throttle_scope = "signup" + def perform_authentication(self, request: Request) -> None: + if self.action == "create": + return + return super().perform_authentication(request) + def get_throttles(self): """ Used for throttling create(signup) action @@ -105,6 +120,12 @@ def get_throttles(self): throttles = [ScopedRateThrottle()] return throttles + def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: + response = super().create(request, *args, **kwargs) + if settings.COOKIE_AUTH_ENABLED: + authorise_response(self.user, response) + return response + def perform_destroy(self, instance): instance.delete( delete_orphan_organisations=self.request.data.get( diff --git a/api/poetry.lock b/api/poetry.lock index 1296bf8fb500..83d165798e50 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -1102,27 +1102,27 @@ djangorestframework = ">=3.0" [[package]] name = "djangorestframework-simplejwt" -version = "5.2.2" +version = "5.3.1" description = "A minimal JSON Web Token authentication plugin for Django REST Framework" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "djangorestframework_simplejwt-5.2.2-py3-none-any.whl", hash = "sha256:4c0d2e2513e12587d93501ac091781684a216c3ee614eb3b5a10586aef5ca845"}, - {file = "djangorestframework_simplejwt-5.2.2.tar.gz", hash = "sha256:d27d4bcac2c6394f678dea8b4d0d511c6e18a7f2eb8aaeeb8a7de601aeb77c42"}, + {file = "djangorestframework_simplejwt-5.3.1-py3-none-any.whl", hash = "sha256:381bc966aa46913905629d472cd72ad45faa265509764e20ffd440164c88d220"}, + {file = "djangorestframework_simplejwt-5.3.1.tar.gz", hash = "sha256:6c4bd37537440bc439564ebf7d6085e74c5411485197073f508ebdfa34bc9fae"}, ] [package.dependencies] -django = "*" -djangorestframework = "*" +django = ">=3.2" +djangorestframework = ">=3.12" pyjwt = ">=1.7.1,<3" [package.extras] crypto = ["cryptography (>=3.3.1)"] -dev = ["Sphinx (>=1.6.5,<2)", "cryptography", "flake8", "ipython", "isort", "pep8", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "sphinx-rtd-theme (>=0.1.9)", "tox", "twine", "wheel"] -doc = ["Sphinx (>=1.6.5,<2)", "sphinx-rtd-theme (>=0.1.9)"] +dev = ["Sphinx (>=1.6.5,<2)", "cryptography", "flake8", "freezegun", "ipython", "isort", "pep8", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel"] +doc = ["Sphinx (>=1.6.5,<2)", "sphinx_rtd_theme (>=0.1.9)"] lint = ["flake8", "isort", "pep8"] python-jose = ["python-jose (==3.3.0)"] -test = ["cryptography", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] +test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] [[package]] name = "djoser" @@ -2145,6 +2145,16 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -3315,6 +3325,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -3322,8 +3333,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -3340,6 +3358,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -3347,6 +3366,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -4161,4 +4181,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "80004eaae4f8296e4ce3f3f7459db21bd73c1f29c675e5de0ff157322715f882" +content-hash = "39e82dd19f6474cd680ee9b42b36c2a3018b8dd1704e03fbdebdabdbaf620a96" diff --git a/api/pyproject.toml b/api/pyproject.toml index ed4e4a0b1978..57b24ea7a734 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -172,6 +172,7 @@ pyotp = "^2.9.0" flagsmith-task-processor = { git = "https://github.com/Flagsmith/flagsmith-task-processor", tag = "v1.0.2" } flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.0.0" } tzdata = "^2024.1" +djangorestframework-simplejwt = "^5.3.1" [tool.poetry.group.auth-controller] optional = true diff --git a/api/tests/integration/custom_auth/end_to_end/test_custom_auth_integration.py b/api/tests/integration/custom_auth/end_to_end/test_custom_auth_integration.py index e274475119b5..272d6e6750d9 100644 --- a/api/tests/integration/custom_auth/end_to_end/test_custom_auth_integration.py +++ b/api/tests/integration/custom_auth/end_to_end/test_custom_auth_integration.py @@ -9,6 +9,7 @@ from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient, override_settings +from rest_framework_simplejwt.tokens import SlidingToken from organisations.invites.models import Invite from organisations.models import Organisation @@ -286,6 +287,197 @@ def test_login_workflow_with_mfa_enabled( assert current_user_response.json()["email"] == email +@override_settings(COOKIE_AUTH_ENABLED=True) +def test_register_and_login_workflows__jwt_cookie( + db: None, + api_client: APIClient, +) -> None: + # Given + email = "test@example.com" + password = FFAdminUser.objects.make_random_password() + register_url = reverse("api-v1:custom_auth:ffadminuser-list") + login_url = reverse("api-v1:custom_auth:custom-mfa-authtoken-login") + logout_url = reverse("api-v1:custom_auth:jwt-logout") + protected_resource_url = reverse("api-v1:projects:project-list") + register_data = { + "first_name": "test", + "last_name": "last_name", + "email": email, + "password": password, + "re_password": password, + } + login_data = { + "email": email, + "password": password, + } + + # When & Then + # verify the cookie is returned on registration + response = api_client.post(register_url, data=register_data) + assert response.status_code == status.HTTP_201_CREATED + assert (jwt_access_cookie := response.cookies.get("jwt")) is not None + assert jwt_access_cookie["httponly"] + + # verify the classic token is not returned on registration + assert "key" not in response.json() + + # verify the register cookie works when accessing a protected endpoint + response = api_client.get( + protected_resource_url, + ) + assert response.status_code == status.HTTP_200_OK + + # now verify we can login with the same credentials + response = api_client.post(login_url, data=login_data) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert (jwt_access_cookie := response.cookies.get("jwt")) is not None + assert jwt_access_cookie["httponly"] + + # verify the classic token is not returned on login + assert not response.data + + # verify the login cookie works when accessing a protected endpoint + response = api_client.get(protected_resource_url) + assert response.status_code == status.HTTP_200_OK + + # logout + response = api_client.post(logout_url) + assert response.status_code == status.HTTP_204_NO_CONTENT + + # verify the login cookie does not work anymore + response = api_client.get(protected_resource_url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + # login again + response = api_client.post(login_url, data=login_data) + assert response.status_code == status.HTTP_204_NO_CONTENT + new_jwt_access_cookie = response.cookies.get("jwt") + + # verify new token is different from the old one + assert new_jwt_access_cookie != jwt_access_cookie + + +@override_settings(COOKIE_AUTH_ENABLED=True) +def test_login_workflow__jwt_cookie__mfa_enabled( + db: None, + api_client: APIClient, +) -> None: + # Given + email = "test@example.com" + password = FFAdminUser.objects.make_random_password() + register_url = reverse("api-v1:custom_auth:ffadminuser-list") + create_mfa_method_url = reverse( + "api-v1:custom_auth:mfa-activate", kwargs={"method": "app"} + ) + login_url = reverse("api-v1:custom_auth:custom-mfa-authtoken-login") + login_confirm_url = reverse("api-v1:custom_auth:mfa-authtoken-login-code") + logout_url = reverse("api-v1:custom_auth:jwt-logout") + register_data = { + "first_name": "test", + "last_name": "last_name", + "email": email, + "password": password, + "re_password": password, + } + login_data = { + "email": email, + "password": password, + } + response = api_client.post(register_url, data=register_data) + jwt_access_cookie = response.cookies.get("jwt") + response = api_client.post(create_mfa_method_url) + secret = response.json()["secret"] + totp = pyotp.TOTP(secret) + confirm_mfa_data = {"code": totp.now()} + confirm_mfa_method_url = reverse( + "api-v1:custom_auth:mfa-activate-confirm", kwargs={"method": "app"} + ) + api_client.post(confirm_mfa_method_url, data=confirm_mfa_data) + api_client.post(logout_url) + + # When & Then + # verify the cookie is returned on login + response = api_client.post(login_url, data=login_data) + ephemeral_token = response.json()["ephemeral_token"] + confirm_login_data = {"ephemeral_token": ephemeral_token, "code": totp.now()} + response = api_client.post(login_confirm_url, data=confirm_login_data) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert (jwt_access_cookie := response.cookies.get("jwt")) is not None + assert jwt_access_cookie["httponly"] + + # verify the classic token is not returned on login + assert not response.data + + +# In the real world, setting `COOKIE_AUTH_ENABLED` to `True` +# changes default CORS setting values. +# Due to how Django settings are loaded for tests, +# we have to override CORS settings manually. +@override_settings( + COOKIE_AUTH_ENABLED=True, + DOMAIN_OVERRIDE="testhost.com", + CORS_ORIGIN_ALLOW_ALL=False, + CORS_ALLOW_CREDENTIALS=True, +) +def test_login_workflow__jwt_cookie__cors_headers_expected( + db: None, + api_client: APIClient, +) -> None: + # Given + email = "test@example.com" + password = FFAdminUser.objects.make_random_password() + register_url = reverse("api-v1:custom_auth:ffadminuser-list") + protected_resource_url = reverse("api-v1:projects:project-list") + register_data = { + "first_name": "test", + "last_name": "last_name", + "email": email, + "password": password, + "re_password": password, + } + api_client.post(register_url, data=register_data) + + # When + response = api_client.get( + protected_resource_url, + HTTP_ORIGIN="http://testhost.com", + ) + + # Then + assert response.headers["Access-Control-Allow-Origin"] == "http://testhost.com" + + +@override_settings(COOKIE_AUTH_ENABLED=True) +def test_login_workflow__jwt_cookie__invalid_token__no_cookies_expected( + db: None, + api_client: APIClient, +) -> None: + # Given + email = "test@example.com" + password = FFAdminUser.objects.make_random_password() + register_url = reverse("api-v1:custom_auth:ffadminuser-list") + protected_resource_url = reverse("api-v1:projects:project-list") + register_data = { + "first_name": "test", + "last_name": "last_name", + "email": email, + "password": password, + "re_password": password, + } + response = api_client.post(register_url, data=register_data) + jwt_access_cookie = response.cookies.get("jwt") + + # cookie is invalidated server-side but is still attached to the client + SlidingToken(jwt_access_cookie.value).blacklist() + + # When + response = api_client.get(protected_resource_url) + + # Then + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert not response.cookies.get("jwt") + + def test_throttle_login_workflows( api_client: APIClient, db: None, diff --git a/api/tests/unit/core/test_helpers.py b/api/tests/unit/core/test_helpers.py index 03e3455a2714..e57483bd7045 100644 --- a/api/tests/unit/core/test_helpers.py +++ b/api/tests/unit/core/test_helpers.py @@ -5,7 +5,7 @@ from django.contrib.sites.models import Site if typing.TYPE_CHECKING: - from pytest_django.fixtures import SettingsWrapper + from pytest_django.fixtures import DjangoAssertNumQueries, SettingsWrapper from pytest_mock import MockerFixture pytestmark = pytest.mark.django_db @@ -26,13 +26,13 @@ def test_get_current_site_url_returns_correct_url_if_site_exists( assert url == f"https://{expected_domain}" -def test_get_current_site_url_uses_default_url_if_site_does_not_exists( +def test_get_current_site_url_uses_default_url_if_site_does_not_exist( settings: "SettingsWrapper", ) -> None: # Given expected_domain = "some-testing-url.com" settings.DEFAULT_DOMAIN = expected_domain - settings.SITE_ID = None + Site.objects.all().delete() # When url = get_current_site_url() @@ -41,6 +41,35 @@ def test_get_current_site_url_uses_default_url_if_site_does_not_exists( assert url == f"https://{expected_domain}" +def test_get_current_site_url__site_created__cached_return_expected( + settings: "SettingsWrapper", + django_assert_num_queries: "DjangoAssertNumQueries", +) -> None: + # Given + expected_domain_without_site = "some-new-testing-url.com" + expected_domain_with_site = "some-testing-url.com" + settings.DEFAULT_DOMAIN = expected_domain_without_site + Site.objects.all().delete() + + # When + with django_assert_num_queries(1): + get_current_site_url() + url_without_site = get_current_site_url() + + settings.SITE_ID = Site.objects.create( + name="test_site", + domain=expected_domain_with_site, + ).id + + with django_assert_num_queries(1): + get_current_site_url() + url_with_site = get_current_site_url() + + # Then + assert url_without_site == f"https://{expected_domain_without_site}" + assert url_with_site == f"https://{expected_domain_with_site}" + + def test_get_current_site__domain_override__with_site__return_expected( settings: "SettingsWrapper", ) -> None: @@ -62,7 +91,7 @@ def test_get_current_site__domain_override__no_site__return_expected( settings: "SettingsWrapper", ) -> None: # Given - settings.SITE_ID = None + Site.objects.all().delete() expected_domain = "some-testing-url.com" settings.DOMAIN_OVERRIDE = expected_domain diff --git a/frontend/api/index.js b/frontend/api/index.js index 1d13210832f6..14150068d36e 100755 --- a/frontend/api/index.js +++ b/frontend/api/index.js @@ -119,6 +119,7 @@ app.get('/config/project-overrides', (req, res) => { { name: 'albacross', value: process.env.ALBACROSS_CLIENT_ID }, { name: 'useSecureCookies', value: envToBool('USE_SECURE_COOKIES', true) }, { name: 'cookieSameSite', value: process.env.USE_SECURE_COOKIES }, + { name: 'cookieAuthEnabled', value: process.env.COOKIE_AUTH_ENABLED }, { name: 'githubAppURL', value: process.env.GITHUB_APP_URL, diff --git a/frontend/common/data/base/_data.js b/frontend/common/data/base/_data.js index 17c7e529d13a..d2ec3cb63172 100644 --- a/frontend/common/data/base/_data.js +++ b/frontend/common/data/base/_data.js @@ -9,6 +9,7 @@ const getQueryString = (params) => { module.exports = { _request(method, _url, data, headers = {}) { const options = { + credentials: Project.cookieAuthEnabled ? 'include' : undefined, headers: { 'Accept': 'application/json', ...headers, @@ -22,7 +23,7 @@ module.exports = { options.headers['Content-Type'] = 'application/json; charset=utf-8' if ( - (this.token && !isExternal) || + (this.token && !isExternal && !Project.cookieAuthEnabled) || (this.token && isExternal && method !== 'get') ) { // add auth tokens to headers of all requests diff --git a/frontend/common/service.ts b/frontend/common/service.ts index 725d930f1938..8a2f2817f547 100644 --- a/frontend/common/service.ts +++ b/frontend/common/service.ts @@ -16,6 +16,7 @@ export const baseApiOptions = (queryArgs?: Partial) => { | 'extractRehydrationInfo' > = { baseQuery: fetchBaseQuery({ + credentials: Project.cookieAuthEnabled ? 'include' : 'omit', // 'include' for cookies, 'omit' if not baseUrl: Project.api, prepareHeaders: async (headers, { endpoint, getState }) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -29,10 +30,10 @@ export const baseApiOptions = (queryArgs?: Partial) => { ) { try { const token = _data.token - if (token) { + if (token && !Project.cookieAuthEnabled) { headers.set('Authorization', `Token ${token}`) } - } catch (e) {} + } catch (e) { } } return headers diff --git a/frontend/common/stores/account-store.js b/frontend/common/stores/account-store.js index bd847484fcd9..8575cfb62a4a 100644 --- a/frontend/common/stores/account-store.js +++ b/frontend/common/stores/account-store.js @@ -6,6 +6,7 @@ const data = require('../data/base/_data') import Constants from 'common/constants' import dataRelay from 'data-relay' import { sortBy } from 'lodash' +import Project from 'common/project' const controller = { acceptInvite: (id) => { @@ -186,7 +187,7 @@ const controller = { return } - data.setToken(res.key) + data.setToken(Project.cookieAuthEnabled ? 'true' : res.key) return controller.onLogin() }) .catch((e) => API.ajaxHandler(store, e)) @@ -218,14 +219,14 @@ const controller = { return } - data.setToken(res.key) + data.setToken(Project.cookieAuthEnabled ? 'true' : res.key) return controller.onLogin() }) .catch((e) => API.ajaxHandler(store, e)) }, onLogin: (skipCaching) => { if (!skipCaching) { - API.setCookie('t', data.token) + API.setCookie('t', Project.cookieAuthEnabled ? 'true' : data.token) } return controller.getOrganisations() }, @@ -241,15 +242,15 @@ const controller = { .post(`${Project.api}auth/users/`, { email, first_name, + invite_hash: API.getInvite() || undefined, last_name, marketing_consent_given, password, referrer: API.getReferrer() || '', sign_up_type: API.getInviteType(), - invite_hash: API.getInvite() || undefined, }) .then((res) => { - data.setToken(res.key) + data.setToken(Project.cookieAuthEnabled ? 'true' : res.key) API.trackEvent(Constants.events.REGISTER) if (API.getReferrer()) { API.trackEvent( @@ -291,7 +292,7 @@ const controller = { store.loading() store.user = {} - data.setToken(token) + data.setToken(Project.cookieAuthEnabled ? 'true' : token) return controller.onLogin() }, @@ -328,12 +329,20 @@ const controller = { } else if (!user) { store.ephemeral_token = null AsyncStorage.clear() - API.setCookie('t', '') - data.setToken(null) - API.reset().finally(() => { - store.model = user - store.organisation = null - store.trigger('logout') + if (!data.token) { + return + } + ;(Project.cookieAuthEnabled + ? data.post(`${Project.api}auth/logout/`, {}) + : Promise.resolve() + ).finally(() => { + API.setCookie('t', '') + data.setToken(null) + API.reset().finally(() => { + store.model = user + store.organisation = null + store.trigger('logout') + }) }) } }, @@ -348,7 +357,7 @@ const controller = { .then((res) => { store.model = null API.trackEvent(Constants.events.LOGIN) - data.setToken(res.key) + data.setToken(Project.cookieAuthEnabled ? 'true' : res.key) store.ephemeral_token = null controller.onLogin() }) diff --git a/frontend/web/main.js b/frontend/web/main.js index c4d140bc6ff2..003a5cc55210 100644 --- a/frontend/web/main.js +++ b/frontend/web/main.js @@ -8,6 +8,7 @@ import { createBrowserHistory } from 'history' import ToastMessages from './project/toast' import routes from './routes' import Utils from 'common/utils/utils' +import Project from 'common/project' import AccountStore from 'common/stores/account-store' import data from 'common/data/base/_data' @@ -26,7 +27,7 @@ if (params.token) { } // Render the React application to the DOM -const res = API.getCookie('t') +const res = Project.cookieAuthEnabled ? 'true' : API.getCookie('t') const event = API.getEvent() if (event) { @@ -41,7 +42,8 @@ if (event) { } const isInvite = document.location.href.includes('invite') -if (res && !isInvite) { +const isOauth = document.location.href.includes('/oauth') +if (res && !isInvite && !isOauth) { AppActions.setToken(res) } diff --git a/frontend/webpack/plugins.js b/frontend/webpack/plugins.js index e19630316da5..703e98d5bb24 100644 --- a/frontend/webpack/plugins.js +++ b/frontend/webpack/plugins.js @@ -7,7 +7,7 @@ module.exports = [ new webpack.DefinePlugin({ E2E: process.env.E2E, SENTRY_RELEASE_VERSION: true, - DYNATRACE_URL: !!process.env.DYNATRACE_URL && JSON.stringify(process.env.DYNATRACE_URL) + DYNATRACE_URL: !!process.env.DYNATRACE_URL && JSON.stringify(process.env.DYNATRACE_URL), }), // // Fixes warning in moment-with-locales.min.js // // Module not found: Error: Can't resolve './locale' in ... diff --git a/infrastructure/aws/staging/ecs-task-definition-web.json b/infrastructure/aws/staging/ecs-task-definition-web.json index 98c16f29c4f4..1a63022d1926 100644 --- a/infrastructure/aws/staging/ecs-task-definition-web.json +++ b/infrastructure/aws/staging/ecs-task-definition-web.json @@ -281,4 +281,4 @@ ], "cpu": "1024", "memory": "2048" -} +} \ No newline at end of file