From a86f701530192649d4eee01592fbcbe228372f0f Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Fri, 17 Jan 2025 17:35:43 -0600 Subject: [PATCH] Add env FXA_TOKEN_AUTH_VERSION Setting FXA_TOKEN_AUTH_VERSION to 2024 (the default) will use the existing authentication for FxA bearer tokens. Setting it to 2025 will use the new authentication method. When the new authentication is proven, the 2024 version can be deleted. --- api/authentication.py | 191 +++++++- api/tests/authentication_2024_tests.py | 469 ++++++++++++++++++++ api/tests/authentication_tests.py | 7 + api/tests/conftest.py | 1 + api/tests/privaterelay_views_tests.py | 17 +- api/tests/terms_accepted_user_2024_tests.py | 272 ++++++++++++ api/views/privaterelay.py | 130 +++++- privaterelay/settings.py | 3 + 8 files changed, 1082 insertions(+), 8 deletions(-) create mode 100644 api/tests/authentication_2024_tests.py create mode 100644 api/tests/terms_accepted_user_2024_tests.py diff --git a/api/authentication.py b/api/authentication.py index f36dcf3172..0275cc1495 100644 --- a/api/authentication.py +++ b/api/authentication.py @@ -14,10 +14,17 @@ from allauth.socialaccount.models import SocialAccount from codetiming import Timer from markus.utils import generate_tag -from rest_framework.authentication import TokenAuthentication +from rest_framework.authentication import ( + BaseAuthentication, + TokenAuthentication, + get_authorization_header, +) from rest_framework.exceptions import ( APIException, AuthenticationFailed, + NotFound, + ParseError, + PermissionDenied, ) from rest_framework.request import Request @@ -28,6 +35,28 @@ settings.SOCIALACCOUNT_PROVIDERS["fxa"]["OAUTH_ENDPOINT"] ) +# Specify the version strings in FXA_TOKEN_AUTH_VERSION +# +# The older version ("2024") works, but has a few issues. +# The cache key changes between Python instances, so little or no cache hits are used. +# Fetching a profile takes a few seconds, in which time another process can create a +# SocialAccount, leading to IntegrityError. Some of these are tracked in MPP-3505. +# +# The newer version ("2025") addresses these issues, works more like a standard DRF +# authentication class, expands the logged data, and tracks the time to call Accounts +# introspection and profile APIs. However, it is unproven, so we're using an +# environment variable to be able to try it in stage before production, and to +# revert with a config change only. +# +# The names are designed to be annoying so they will be removed. The old code has +# the suffix _2024 and the new code _2025 (when needed). When the new code is +# proven, the old code can be removed with minimal name changes. +# +# ruff thinks the strings "2024" and "2025" are passwords (check S105 / S106). +# These constants allow telling ruff to ignore them once. +FXA_TOKEN_AUTH_OLD_AND_PROVEN = "2024" # noqa: S105 +FXA_TOKEN_AUTH_NEW_AND_BUSTED = "2025" # noqa: S105 + class CachedFxaIntrospectResponse(TypedDict, total=False): """The data stored in the cache to avoid multiple introspection requests.""" @@ -62,6 +91,11 @@ class FxaIntrospectCompleteData(TypedDict): exp: NotRequired[int] +def get_cache_key_2024(token): + """note: hash() returns different results in different Python processes.""" + return hash(token) + + def get_cache_key(token: str) -> str: return f"introspect_result:v1:{sha256(token.encode()).hexdigest()}" @@ -312,6 +346,90 @@ def load_introspection_result_from_cache( return response +def introspect_token_2024(token: str) -> dict[str, Any]: + try: + fxa_resp = requests.post( + INTROSPECT_TOKEN_URL, + json={"token": token}, + timeout=settings.FXA_REQUESTS_TIMEOUT_SECONDS, + ) + except Exception as exc: + logger.error( + "Could not introspect token with FXA.", + extra={"error_cls": type(exc), "error": shlex.quote(str(exc))}, + ) + raise AuthenticationFailed("Could not introspect token with FXA.") + + fxa_resp_data = {"status_code": fxa_resp.status_code, "json": {}} + try: + fxa_resp_data["json"] = fxa_resp.json() + except requests.exceptions.JSONDecodeError: + logger.error( + "JSONDecodeError from FXA introspect response.", + extra={"fxa_response": shlex.quote(fxa_resp.text)}, + ) + raise AuthenticationFailed("JSONDecodeError from FXA introspect response") + return fxa_resp_data + + +def get_fxa_uid_from_oauth_token_2024(token: str, use_cache: bool = True) -> str: + # set a default cache_timeout, but this will be overridden to match + # the 'exp' time in the JWT returned by FxA + cache_timeout = 60 + cache_key = get_cache_key_2024(token) + + if not use_cache: + fxa_resp_data = introspect_token_2024(token) + else: + # set a default fxa_resp_data, so any error during introspection + # will still cache for at least cache_timeout to prevent an outage + # from causing useless run-away repetitive introspection requests + fxa_resp_data = {"status_code": None, "json": {}} + try: + cached_fxa_resp_data = cache.get(cache_key) + + if cached_fxa_resp_data: + fxa_resp_data = cached_fxa_resp_data + else: + # no cached data, get new + fxa_resp_data = introspect_token_2024(token) + except AuthenticationFailed: + raise + finally: + # Store potential valid response, errors, inactive users, etc. from FxA + # for at least 60 seconds. Valid access_token cache extended after checking. + cache.set(cache_key, fxa_resp_data, cache_timeout) + + if fxa_resp_data["status_code"] is None: + raise APIException("Previous FXA call failed, wait to retry.") + + if not fxa_resp_data["status_code"] == 200: + raise APIException("Did not receive a 200 response from FXA.") + + if not fxa_resp_data["json"].get("active"): + raise AuthenticationFailed("FXA returned active: False for token.") + + # FxA user is active, check for the associated Relay account + if (raw_fxa_uid := fxa_resp_data.get("json", {}).get("sub")) is None: + raise NotFound("FXA did not return an FXA UID.") + fxa_uid = str(raw_fxa_uid) + + # cache valid access_token and fxa_resp_data until access_token expiration + # TODO: revisit this since the token can expire before its time + if isinstance(fxa_resp_data.get("json", {}).get("exp"), int): + # Note: FXA iat and exp are timestamps in *milliseconds* + fxa_token_exp_time = int(fxa_resp_data["json"]["exp"] / 1000) + now_time = int(datetime.now(UTC).timestamp()) + fxa_token_exp_cache_timeout = fxa_token_exp_time - now_time + if fxa_token_exp_cache_timeout > cache_timeout: + # cache until access_token expires (matched Relay user) + # this handles cases where the token already expired + cache_timeout = fxa_token_exp_cache_timeout + cache.set(cache_key, fxa_resp_data, cache_timeout) + + return fxa_uid + + def introspect_token(token: str) -> IntrospectionResponse | IntrospectionError: """ Validate an Accounts OAuth token with the introspect API. @@ -431,7 +549,76 @@ def introspect_and_cache_token( return fxa_resp -class FxaTokenAuthentication(TokenAuthentication): +class FxaTokenAuthentication(BaseAuthentication): + """Pick 2024 or 2025 version based on settings""" + + _impl: FxaTokenAuthentication2024 | FxaTokenAuthentication2025 + + def __init__(self) -> None: + if settings.FXA_TOKEN_AUTH_VERSION == FXA_TOKEN_AUTH_NEW_AND_BUSTED: + self._impl = FxaTokenAuthentication2025() + else: + self._impl = FxaTokenAuthentication2024() + + def authenticate_header(self, request: Request) -> Any | str | None: + return self._impl.authenticate_header(request) + + def authenticate( + self, request: Request + ) -> None | tuple[User | AnonymousUser, IntrospectionResponse]: + return self._impl.authenticate(request) + + +class FxaTokenAuthentication2024(BaseAuthentication): + def authenticate_header(self, request): + # Note: we need to implement this function to make DRF return a 401 status code + # when we raise AuthenticationFailed, rather than a 403. See: + # https://www.django-rest-framework.org/api-guide/authentication/#custom-authentication + return "Bearer" + + def authenticate(self, request): + authorization = get_authorization_header(request).decode() + if not authorization or not authorization.startswith("Bearer "): + # If the request has no Bearer token, return None to attempt the next + # auth scheme in the REST_FRAMEWORK AUTHENTICATION_CLASSES list + return None + + token = authorization.split(" ")[1] + if token == "": + raise ParseError("Missing FXA Token after 'Bearer'.") + + use_cache = True + method = request.method + if method in ["POST", "DELETE", "PUT"]: + use_cache = False + if method == "POST" and request.path == "/api/v1/relayaddresses/": + use_cache = True + fxa_uid = get_fxa_uid_from_oauth_token_2024(token, use_cache) + try: + # MPP-3021: select_related user object to save DB query + sa = SocialAccount.objects.filter( + uid=fxa_uid, provider="fxa" + ).select_related("user")[0] + except IndexError: + raise PermissionDenied( + "Authenticated user does not have a Relay account." + " Have they accepted the terms?" + ) + user = sa.user + + if not user.is_active: + raise PermissionDenied( + "Authenticated user does not have an active Relay account." + " Have they been deactivated?" + ) + + if user: + return (user, token) + else: + raise NotFound() + + +class FxaTokenAuthentication2025(TokenAuthentication): """ Implement authentication with a Mozilla Account bearer token. diff --git a/api/tests/authentication_2024_tests.py b/api/tests/authentication_2024_tests.py new file mode 100644 index 0000000000..c746b25562 --- /dev/null +++ b/api/tests/authentication_2024_tests.py @@ -0,0 +1,469 @@ +from datetime import datetime +from typing import NotRequired, TypedDict + +from django.core.cache import cache +from django.test import RequestFactory, TestCase, override_settings + +import responses +from allauth.socialaccount.models import SocialAccount +from model_bakery import baker +from rest_framework.exceptions import APIException, AuthenticationFailed, NotFound +from rest_framework.test import APIClient + +from ..authentication import ( + FXA_TOKEN_AUTH_OLD_AND_PROVEN, + INTROSPECT_TOKEN_URL, +) +from ..authentication import ( + FxaTokenAuthentication2024 as FxaTokenAuthentication, +) +from ..authentication import ( + get_cache_key_2024 as get_cache_key, +) +from ..authentication import ( + get_fxa_uid_from_oauth_token_2024 as get_fxa_uid_from_oauth_token, +) +from ..authentication import ( + introspect_token_2024 as introspect_token, +) + +MOCK_BASE = "api.authentication" + +# TODO MPP-3527 - Many tests mock FxA responses. This one should specify that it is +# mocking the introspection URL. It could also be refactored to a pytest fixture, or a +# nullable. + + +class FxaResponse(TypedDict, total=False): + active: bool + sub: str + exp: int + error: str + + +class CachedFxaResponse(TypedDict): + status_code: int + json: NotRequired[FxaResponse | str] + + +def _setup_fxa_response( + status_code: int, json: FxaResponse | str | None = None +) -> CachedFxaResponse: + responses.add( + responses.POST, + INTROSPECT_TOKEN_URL, + status=status_code, + json=json, + ) + if json is None: + return {"status_code": status_code} + return {"status_code": status_code, "json": json} + + +@override_settings(FXA_TOKEN_AUTH_VERSION=FXA_TOKEN_AUTH_OLD_AND_PROVEN) +class AuthenticationMiscellaneous(TestCase): + def setUp(self): + self.auth = FxaTokenAuthentication + self.factory = RequestFactory() + self.path = "/api/v1/relayaddresses" + self.fxa_verify_path = INTROSPECT_TOKEN_URL + self.uid = "relay-user-fxa-uid" + + def tearDown(self): + cache.clear() + + @responses.activate + def test_introspect_token_catches_JSONDecodeError_raises_AuthenticationFailed(self): + _setup_fxa_response(200) + invalid_token = "invalid-123" + + try: + introspect_token(invalid_token) + except AuthenticationFailed as e: + assert str(e.detail) == "JSONDecodeError from FXA introspect response" + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + return + self.fail("Should have raised AuthenticationFailed") + + @responses.activate + def test_introspect_token_returns_fxa_introspect_response(self): + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + exp_time = (now_time + 60 * 60) * 1000 + json_data: FxaResponse = { + "active": True, + "sub": self.uid, + "exp": exp_time, + } + status_code = 200 + expected_fxa_resp_data = {"status_code": status_code, "json": json_data} + _setup_fxa_response(status_code, json_data) + valid_token = "valid-123" + cache_key = get_cache_key(valid_token) + + assert cache.get(cache_key) is None + + fxa_resp_data = introspect_token(valid_token) + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert fxa_resp_data == expected_fxa_resp_data + + @responses.activate + def test_get_fxa_uid_from_oauth_token_returns_cached_response(self): + user_token = "user-123" + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + exp_time = (now_time + 60 * 60) * 1000 + fxa_response = _setup_fxa_response( + 200, {"active": True, "sub": self.uid, "exp": exp_time} + ) + cache_key = get_cache_key(user_token) + + assert cache.get(cache_key) is None + + # get FxA uid for the first time + fxa_uid = get_fxa_uid_from_oauth_token(user_token) + assert fxa_uid == self.uid + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert cache.get(cache_key) == fxa_response + + # now check that the 2nd call did NOT make another fxa request + fxa_uid = get_fxa_uid_from_oauth_token(user_token) + assert fxa_uid == self.uid + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + + @responses.activate + def test_get_fxa_uid_from_oauth_token_status_code_None_uses_cached_response_returns_error_response( # noqa: E501 + self, + ) -> None: + _setup_fxa_response(200) + invalid_token = "invalid-123" + cache_key = get_cache_key(invalid_token) + + assert cache.get(cache_key) is None + + # get fxa response with no status code for the first time + try: + get_fxa_uid_from_oauth_token(invalid_token) + except AuthenticationFailed as e: + assert str(e.detail) == "JSONDecodeError from FXA introspect response" + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert cache.get(cache_key) == {"status_code": None, "json": {}} + + # now check that the 2nd call did NOT make another fxa request + try: + get_fxa_uid_from_oauth_token(invalid_token) + except APIException as e: + assert str(e.detail) == "Previous FXA call failed, wait to retry." + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + return + self.fail("Should have raised APIException") + + @responses.activate + def test_get_fxa_uid_from_oauth_token_status_code_not_200_uses_cached_response_returns_error_response( # noqa: E501 + self, + ) -> None: + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + exp_time = (now_time + 60 * 60) * 1000 + fxa_response = _setup_fxa_response( + 401, {"active": False, "sub": self.uid, "exp": exp_time} + ) + invalid_token = "invalid-123" + cache_key = get_cache_key(invalid_token) + + assert cache.get(cache_key) is None + + # get fxa response with none 200 response for the first time + try: + get_fxa_uid_from_oauth_token(invalid_token) + except APIException as e: + assert str(e.detail) == "Did not receive a 200 response from FXA." + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert cache.get(cache_key) == fxa_response + + # now check that the 2nd call did NOT make another fxa request + try: + get_fxa_uid_from_oauth_token(invalid_token) + except APIException as e: + assert str(e.detail) == "Did not receive a 200 response from FXA." + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + return + self.fail("Should have raised APIException") + + @responses.activate + def test_get_fxa_uid_from_oauth_token_not_active_uses_cached_response_returns_error_response( # noqa: E501 + self, + ) -> None: + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + old_exp_time = (now_time - 60 * 60) * 1000 + fxa_response = _setup_fxa_response( + 200, {"active": False, "sub": self.uid, "exp": old_exp_time} + ) + invalid_token = "invalid-123" + cache_key = get_cache_key(invalid_token) + + assert cache.get(cache_key) is None + + # get fxa response with token inactive for the first time + try: + get_fxa_uid_from_oauth_token(invalid_token) + except AuthenticationFailed as e: + assert str(e.detail) == "FXA returned active: False for token." + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert cache.get(cache_key) == fxa_response + + # now check that the 2nd call did NOT make another fxa request + try: + get_fxa_uid_from_oauth_token(invalid_token) + except AuthenticationFailed as e: + assert str(e.detail) == "FXA returned active: False for token." + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + return + self.fail("Should have raised AuthenticationFailed") + + @responses.activate + def test_get_fxa_uid_from_oauth_token_returns_fxa_response_with_no_fxa_uid(self): + user_token = "user-123" + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + exp_time = (now_time + 60 * 60) * 1000 + fxa_response = _setup_fxa_response(200, {"active": True, "exp": exp_time}) + cache_key = get_cache_key(user_token) + + assert cache.get(cache_key) is None + + # get fxa response with no fxa uid for the first time + try: + get_fxa_uid_from_oauth_token(user_token) + except NotFound as e: + assert str(e.detail) == "FXA did not return an FXA UID." + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert cache.get(cache_key) == fxa_response + + # now check that the 2nd call did NOT make another fxa request + try: + get_fxa_uid_from_oauth_token(user_token) + except NotFound as e: + assert str(e.detail) == "FXA did not return an FXA UID." + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + return + self.fail("Should have raised AuthenticationFailed") + + +@override_settings(FXA_TOKEN_AUTH_VERSION=FXA_TOKEN_AUTH_OLD_AND_PROVEN) +class FxaTokenAuthenticationTest(TestCase): + def setUp(self) -> None: + self.auth = FxaTokenAuthentication() + self.factory = RequestFactory() + self.path = "/api/v1/relayaddresses/" + self.fxa_verify_path = INTROSPECT_TOKEN_URL + self.uid = "relay-user-fxa-uid" + + def tearDown(self) -> None: + cache.clear() + + def test_no_authorization_header_returns_none(self) -> None: + get_addresses_req = self.factory.get(self.path) + assert self.auth.authenticate(get_addresses_req) is None + + def test_no_bearer_in_authorization_returns_none(self) -> None: + headers = {"Authorization": "unexpected 123"} + get_addresses_req = self.factory.get(self.path, headers=headers) + assert self.auth.authenticate(get_addresses_req) is None + + def test_no_token_returns_400(self) -> None: + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Bearer ") + response = client.get("/api/v1/relayaddresses/") + assert response.status_code == 400 + assert response.json()["detail"] == "Missing FXA Token after 'Bearer'." + + @responses.activate + def test_non_200_resp_from_fxa_raises_error_and_caches(self) -> None: + fxa_response = _setup_fxa_response(401, {"error": "401"}) + not_found_token = "not-found-123" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {not_found_token}") + + assert cache.get(get_cache_key(not_found_token)) is None + + response = client.get("/api/v1/relayaddresses/") + assert response.status_code == 500 + assert response.json()["detail"] == "Did not receive a 200 response from FXA." + + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert cache.get(get_cache_key(not_found_token)) == fxa_response + + # now check that the code does NOT make another fxa request + response = client.get("/api/v1/relayaddresses/") + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + + @responses.activate + def test_non_200_non_json_resp_from_fxa_raises_error_and_caches(self) -> None: + fxa_response = _setup_fxa_response(503, "Bad Gateway") + not_found_token = "fxa-gw-error" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {not_found_token}") + + assert cache.get(get_cache_key(not_found_token)) is None + + response = client.get("/api/v1/relayaddresses/") + assert response.status_code == 500 + + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert cache.get(get_cache_key(not_found_token)) == fxa_response + + # now check that the code does NOT make another fxa request + response = client.get("/api/v1/relayaddresses/") + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + + @responses.activate + def test_inactive_token_responds_with_401(self) -> None: + fxa_response = _setup_fxa_response(200, {"active": False}) + inactive_token = "inactive-123" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {inactive_token}") + + assert cache.get(get_cache_key(inactive_token)) is None + + response = client.get("/api/v1/relayaddresses/") + assert response.status_code == 401 + assert response.json()["detail"] == "FXA returned active: False for token." + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert cache.get(get_cache_key(inactive_token)) == fxa_response + + # now check that the code does NOT make another fxa request + response = client.get("/api/v1/relayaddresses/") + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + + @responses.activate + def test_200_resp_from_fxa_no_matching_user_raises_APIException(self) -> None: + fxa_response = _setup_fxa_response( + 200, {"active": True, "sub": "not-a-relay-user"} + ) + non_user_token = "non-user-123" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {non_user_token}") + + assert cache.get(get_cache_key(non_user_token)) is None + + response = client.get("/api/v1/relayaddresses/") + assert response.status_code == 403 + expected_detail = ( + "Authenticated user does not have a Relay account." + " Have they accepted the terms?" + ) + assert response.json()["detail"] == expected_detail + assert cache.get(get_cache_key(non_user_token)) == fxa_response + + # the code does NOT make another fxa request + response = client.get("/api/v1/relayaddresses/") + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + + @responses.activate + def test_200_resp_from_fxa_inactive_Relay_user_raises_APIException(self) -> None: + sa: SocialAccount = baker.make(SocialAccount, uid=self.uid, provider="fxa") + sa.user.is_active = False + sa.user.save() + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + exp_time = (now_time + 60 * 60) * 1000 + _setup_fxa_response(200, {"active": True, "sub": self.uid, "exp": exp_time}) + inactive_user_token = "inactive-user-123" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {inactive_user_token}") + + response = client.get("/api/v1/relayaddresses/") + assert response.status_code == 403 + expected_detail = ( + "Authenticated user does not have an active Relay account." + " Have they been deactivated?" + ) + assert response.json()["detail"] == expected_detail + + @responses.activate + def test_200_resp_from_fxa_for_user_returns_user_and_caches(self) -> None: + sa: SocialAccount = baker.make(SocialAccount, uid=self.uid, provider="fxa") + user_token = "user-123" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}") + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + exp_time = (now_time + 60 * 60) * 1000 + fxa_response = _setup_fxa_response( + 200, {"active": True, "sub": self.uid, "exp": exp_time} + ) + + assert cache.get(get_cache_key(user_token)) is None + + # check the endpoint status code + response = client.get("/api/v1/relayaddresses/") + assert response.status_code == 200 + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert cache.get(get_cache_key(user_token)) == fxa_response + + # check the function returns the right user + headers = {"Authorization": f"Bearer {user_token}"} + get_addresses_req = self.factory.get(self.path, headers=headers) + auth_return = self.auth.authenticate(get_addresses_req) + assert auth_return == (sa.user, user_token) + + # now check that the 2nd call did NOT make another fxa request + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert cache.get(get_cache_key(user_token)) == fxa_response + + @responses.activate + def test_write_requests_make_calls_to_fxa(self) -> None: + sa: SocialAccount = baker.make(SocialAccount, uid=self.uid, provider="fxa") + user_token = "user-123" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}") + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + exp_time = (now_time + 60 * 60) * 1000 + fxa_response = _setup_fxa_response( + 200, {"active": True, "sub": self.uid, "exp": exp_time} + ) + + assert cache.get(get_cache_key(user_token)) is None + + # check the endpoint status code + response = client.get("/api/v1/relayaddresses/") + assert response.status_code == 200 + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert cache.get(get_cache_key(user_token)) == fxa_response + + # check the function returns the right user + headers = {"Authorization": f"Bearer {user_token}"} + get_addresses_req = self.factory.get(self.path, headers=headers) + auth_return = self.auth.authenticate(get_addresses_req) + assert auth_return == (sa.user, user_token) + + # now check that the 2nd GET request did NOT make another fxa request + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert cache.get(get_cache_key(user_token)) == fxa_response + + headers = {"Authorization": f"Bearer {user_token}"} + + # send POST to /api/v1/relayaddresses and check that cache is used - i.e., + # FXA is *NOT* called + post_addresses_req = self.factory.post(self.path, headers=headers) + auth_return = self.auth.authenticate(post_addresses_req) + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + + # send POST to another API endpoint and check that cache is NOT used + post_webcompat = self.factory.post( + "/api/v1/report_webcompat_issue", headers=headers + ) + auth_return = self.auth.authenticate(post_webcompat) + assert responses.assert_call_count(self.fxa_verify_path, 2) is True + + # send other write requests and check that FXA *IS* called + put_addresses_req = self.factory.put(self.path, headers=headers) + auth_return = self.auth.authenticate(put_addresses_req) + assert responses.assert_call_count(self.fxa_verify_path, 3) is True + + delete_addresses_req = self.factory.delete(self.path, headers=headers) + auth_return = self.auth.authenticate(delete_addresses_req) + assert responses.assert_call_count(self.fxa_verify_path, 4) is True diff --git a/api/tests/authentication_tests.py b/api/tests/authentication_tests.py index 24533e7ec2..8768411cc0 100644 --- a/api/tests/authentication_tests.py +++ b/api/tests/authentication_tests.py @@ -10,6 +10,7 @@ import pytest import responses from allauth.socialaccount.models import SocialAccount, SocialApp +from pytest_django.fixtures import SettingsWrapper from requests import ReadTimeout from rest_framework.exceptions import AuthenticationFailed from rest_framework.test import APIRequestFactory @@ -89,6 +90,12 @@ def mock_timer() -> Iterator[Mock]: yield MockedTimer +@pytest.fixture(autouse=True) +def auth_2025_settings(settings: SettingsWrapper) -> SettingsWrapper: + settings.FXA_TOKEN_AUTH_VERSION = "2025" + return settings + + def setup_fxa_introspect( status_code: int = 200, no_body: bool = False, diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 14547143e9..836c20ae14 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -54,5 +54,6 @@ def fxa_social_app(db: None) -> SocialApp: @pytest.fixture def cache() -> Iterator[BaseCache]: + django_cache.clear() yield django_cache django_cache.clear() diff --git a/api/tests/privaterelay_views_tests.py b/api/tests/privaterelay_views_tests.py index 039f459cef..2c7fc022f5 100644 --- a/api/tests/privaterelay_views_tests.py +++ b/api/tests/privaterelay_views_tests.py @@ -1,4 +1,4 @@ -"""Tests for api/views/email_views.py""" +"""Tests for api/views/privaterelay_views.py""" import logging from typing import Any @@ -7,7 +7,7 @@ from django.contrib.auth.models import User from django.core.cache import BaseCache, cache from django.http import HttpRequest -from django.test import TestCase +from django.test import TestCase, override_settings from django.test.client import Client from django.urls import reverse @@ -17,10 +17,16 @@ from allauth.socialaccount.internal.flows.signup import process_auto_signup from allauth.socialaccount.models import SocialAccount, SocialApp, SocialLogin from model_bakery import baker +from pytest_django.fixtures import SettingsWrapper from requests import ReadTimeout from rest_framework.test import APIClient -from api.authentication import IntrospectionError, IntrospectionResponse, get_cache_key +from api.authentication import ( + FXA_TOKEN_AUTH_NEW_AND_BUSTED, + IntrospectionError, + IntrospectionResponse, + get_cache_key, +) from api.tests.authentication_tests import setup_fxa_introspect from api.views.privaterelay import FXA_PROFILE_URL, _get_fxa_profile_from_bearer_token from privaterelay.models import Profile @@ -231,6 +237,9 @@ def _mock_fxa_profile_response( ) +@override_settings( + FXA_TOKEN_AUTH_VERSION=FXA_TOKEN_AUTH_NEW_AND_BUSTED +) # noqa: S106 # Possible hardcoded password @pytest.mark.usefixtures("fxa_social_app") class TermsAcceptedUserViewTest(TestCase): path = "/api/v1/terms-accepted-user/" @@ -542,7 +551,9 @@ def test_metrics_disabled_user_fxa_uid_not_logged( caplog: pytest.LogCaptureFixture, fxa_social_app: SocialApp, cache: BaseCache, + settings: SettingsWrapper, ) -> None: + settings.FXA_TOKEN_AUTH_VERSION = "2025" caplog.set_level(logging.ERROR) uid = "relay-user-fxa-uid" email = "user@email.com" diff --git a/api/tests/terms_accepted_user_2024_tests.py b/api/tests/terms_accepted_user_2024_tests.py new file mode 100644 index 0000000000..9fe07114bc --- /dev/null +++ b/api/tests/terms_accepted_user_2024_tests.py @@ -0,0 +1,272 @@ +"""Tests for terms_accepted_user_2024 in api/views/privaterelay_views.py""" + +import logging +from datetime import datetime + +from django.core.cache import cache +from django.test import RequestFactory, TestCase, override_settings + +import pytest +import responses +from allauth.account.models import EmailAddress +from allauth.socialaccount.models import SocialAccount +from model_bakery import baker +from rest_framework.test import APIClient + +from api.authentication import ( + FXA_TOKEN_AUTH_OLD_AND_PROVEN, + INTROSPECT_TOKEN_URL, +) +from api.authentication import ( + get_cache_key_2024 as get_cache_key, +) +from api.tests.authentication_2024_tests import _setup_fxa_response +from api.views.privaterelay import FXA_PROFILE_URL +from privaterelay.models import Profile +from privaterelay.tests.utils import log_extra + + +@override_settings( + FXA_TOKEN_AUTH_VERSION=FXA_TOKEN_AUTH_OLD_AND_PROVEN +) # noqa: S106 # Possible hardcoded password +@pytest.mark.usefixtures("fxa_social_app") +class TermsAcceptedUserViewTest(TestCase): + def setUp(self) -> None: + self.factory = RequestFactory() + self.path = "/api/v1/terms-accepted-user/" + self.fxa_verify_path = INTROSPECT_TOKEN_URL + self.uid = "relay-user-fxa-uid" + + def _setup_client(self, token: str) -> None: + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + + def tearDown(self) -> None: + cache.clear() + + @responses.activate + def test_201_new_user_created_and_202_user_exists(self) -> None: + email = "user@email.com" + user_token = "user-123" + self._setup_client(user_token) + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + exp_time = (now_time + 60 * 60) * 1000 + fxa_response = _setup_fxa_response( + 200, {"active": True, "sub": self.uid, "exp": exp_time} + ) + # setup fxa profile response + profile_json = { + "email": email, + "amrValues": ["pwd", "email"], + "twoFactorAuthentication": False, + "metricsEnabled": True, + "uid": self.uid, + "avatar": "https://profile.stage.mozaws.net/v1/avatar/t", + "avatarDefault": False, + } + responses.add( + responses.GET, + FXA_PROFILE_URL, + status=200, + json=profile_json, + ) + cache_key = get_cache_key(user_token) + + # get fxa response with 201 response for new user and profile created + response = self.client.post(self.path) + assert response.status_code == 201 + assert hasattr(response, "data") + assert response.data is None + # ensure no session cookie was set + assert len(response.cookies.keys()) == 1 + assert "csrftoken" in response.cookies + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + assert responses.assert_call_count(FXA_PROFILE_URL, 1) is True + assert cache.get(cache_key) == fxa_response + assert SocialAccount.objects.filter(user__email=email).count() == 1 + assert Profile.objects.filter(user__email=email).count() == 1 + assert Profile.objects.get(user__email=email).created_by == "firefox_resource" + + # now check that the 2nd call returns 202 + response = self.client.post(self.path) + assert response.status_code == 202 + assert hasattr(response, "data") + assert response.data is None + assert responses.assert_call_count(self.fxa_verify_path, 2) is True + assert responses.assert_call_count(FXA_PROFILE_URL, 1) is True + + @responses.activate + def test_failed_profile_fetch_for_new_user_returns_500(self) -> None: + user_token = "user-123" + self._setup_client(user_token) + now_time = int(datetime.now().timestamp()) + exp_time = (now_time + 60 * 60) * 1000 + _setup_fxa_response(200, {"active": True, "sub": self.uid, "exp": exp_time}) + # FxA profile server is down + responses.add(responses.GET, FXA_PROFILE_URL, status=502, body="") + response = self.client.post(self.path) + + assert response.status_code == 500 + assert response.json()["detail"] == ( + "Did not receive a 200 response for account profile." + ) + + def test_no_authorization_header_returns_400(self) -> None: + client = APIClient() + response = client.post(self.path) + + assert response.status_code == 400 + assert response.json()["detail"] == "Missing Bearer header." + + def test_no_token_returns_400(self) -> None: + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Bearer ") + response = client.post(self.path) + + assert response.status_code == 400 + assert response.json()["detail"] == "Missing FXA Token after 'Bearer'." + + @responses.activate + def test_invalid_bearer_token_error_from_fxa_returns_500_and_cache_returns_500( + self, + ) -> None: + _setup_fxa_response(401, {"error": "401"}) + not_found_token = "not-found-123" + self._setup_client(not_found_token) + + assert cache.get(get_cache_key(not_found_token)) is None + + response = self.client.post(self.path) + assert response.status_code == 500 + assert response.json()["detail"] == "Did not receive a 200 response from FXA." + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + + @responses.activate + def test_jsondecodeerror_returns_401_and_cache_returns_500( + self, + ) -> None: + _setup_fxa_response(200) + invalid_token = "invalid-123" + cache_key = get_cache_key(invalid_token) + self._setup_client(invalid_token) + + assert cache.get(cache_key) is None + + # get fxa response with no status code for the first time + response = self.client.post(self.path) + assert response.status_code == 401 + assert ( + response.json()["detail"] == "Jsondecodeerror From Fxa Introspect Response" + ) + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + + @responses.activate + def test_non_200_response_from_fxa_returns_500_and_cache_returns_500( + self, + ) -> None: + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + exp_time = (now_time + 60 * 60) * 1000 + _setup_fxa_response(401, {"active": False, "sub": self.uid, "exp": exp_time}) + invalid_token = "invalid-123" + cache_key = get_cache_key(invalid_token) + self._setup_client(invalid_token) + + assert cache.get(cache_key) is None + + # get fxa response with non-200 response for the first time + response = self.client.post(self.path) + assert response.status_code == 500 + assert response.json()["detail"] == "Did not receive a 200 response from FXA." + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + + @responses.activate + def test_inactive_fxa_oauth_token_returns_401_and_cache_returns_401( + self, + ) -> None: + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + old_exp_time = (now_time - 60 * 60) * 1000 + _setup_fxa_response( + 200, {"active": False, "sub": self.uid, "exp": old_exp_time} + ) + invalid_token = "invalid-123" + cache_key = get_cache_key(invalid_token) + self._setup_client(invalid_token) + + assert cache.get(cache_key) is None + + # get fxa response with token inactive for the first time + response = self.client.post(self.path) + assert response.status_code == 401 + assert response.json()["detail"] == "Fxa Returned Active: False For Token." + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + + @responses.activate + def test_fxa_responds_with_no_fxa_uid_returns_404_and_cache_returns_404( + self, + ) -> None: + user_token = "user-123" + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + exp_time = (now_time + 60 * 60) * 1000 + _setup_fxa_response(200, {"active": True, "exp": exp_time}) + cache_key = get_cache_key(user_token) + self._setup_client(user_token) + + assert cache.get(cache_key) is None + + # get fxa response with no fxa uid for the first time + response = self.client.post(self.path) + assert response.status_code == 404 + assert response.json()["detail"] == "FXA did not return an FXA UID." + assert responses.assert_call_count(self.fxa_verify_path, 1) is True + + +def _setup_client(token: str) -> APIClient: + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + return client + + +@responses.activate +@pytest.mark.usefixtures("fxa_social_app") +def test_duplicate_email_logs_details_for_debugging( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.ERROR) + uid = "relay-user-fxa-uid" + email = "user@email.com" + baker.make(EmailAddress, email=email, verified=True) + user_token = "user-123" + client = _setup_client(user_token) + now_time = int(datetime.now().timestamp()) + # Note: FXA iat and exp are timestamps in *milliseconds* + exp_time = (now_time + 60 * 60) * 1000 + _setup_fxa_response(200, {"active": True, "sub": uid, "exp": exp_time}) + # setup fxa profile response + profile_json = { + "email": email, + "amrValues": ["pwd", "email"], + "twoFactorAuthentication": False, + "metricsEnabled": True, + "uid": uid, + "avatar": "https://profile.stage.mozaws.net/v1/avatar/t", + "avatarDefault": False, + } + responses.add( + responses.GET, + FXA_PROFILE_URL, + status=200, + json=profile_json, + ) + + response = client.post("/api/v1/terms-accepted-user/") + + assert response.status_code == 500 + (rec1,) = caplog.records + rec1_extra = log_extra(rec1) + assert "socialaccount_signup" in rec1.message + assert rec1_extra.get("fxa_uid") == uid + assert rec1_extra.get("social_login_state") == {} diff --git a/api/views/privaterelay.py b/api/views/privaterelay.py index ad223f9e68..9782b4c8cc 100644 --- a/api/views/privaterelay.py +++ b/api/views/privaterelay.py @@ -22,11 +22,13 @@ extend_schema, ) from markus.utils import generate_tag +from rest_framework.authentication import get_authorization_header from rest_framework.decorators import ( api_view, authentication_classes, permission_classes, ) +from rest_framework.exceptions import AuthenticationFailed, ErrorDetail, ParseError from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response @@ -44,7 +46,14 @@ ) from privaterelay.utils import get_countries_info_from_request_and_mapping -from ..authentication import FxaTokenAuthentication, IntrospectionResponse +from ..authentication import ( + FXA_TOKEN_AUTH_NEW_AND_BUSTED, + FxaTokenAuthentication, + IntrospectionResponse, +) +from ..authentication import ( + get_fxa_uid_from_oauth_token_2024 as get_fxa_uid_from_oauth_token, +) from ..permissions import CanManageFlags, HasValidFxaToken, IsActive, IsNewUser, IsOwner from ..serializers.privaterelay import ( FlagSerializer, @@ -294,9 +303,18 @@ def runtime_data(request): }, ) @api_view(["POST"]) -@permission_classes([HasValidFxaToken, IsNewUser | IsActive]) -@authentication_classes([FxaTokenAuthentication]) +@permission_classes([AllowAny]) +@authentication_classes([]) def terms_accepted_user(request: Request) -> Response: + """Pick 2024 or 2025 version based on settings""" + if settings.FXA_TOKEN_AUTH_VERSION == FXA_TOKEN_AUTH_NEW_AND_BUSTED: + # Use request._request to re-do authentication, permissions checks + return terms_accepted_user_2025(request._request) + else: + return terms_accepted_user_2024(request) + + +def terms_accepted_user_2024(request: Request) -> Response: """ Create a Relay user from an FXA token. @@ -304,6 +322,112 @@ def terms_accepted_user(request: Request) -> Response: [api-auth-doc]: https://github.com/mozilla/fx-private-relay/blob/main/docs/api_auth.md#firefox-oauth-token-authentication-and-accept-terms-of-service """ # noqa: E501 + # Setting authentication_classes to empty due to + # authentication still happening despite permissions being set to allowany + # https://forum.djangoproject.com/t/solved-allowany-override-does-not-work-on-apiview/9754 + # TODO: Implement an FXA token authentication class + authorization = get_authorization_header(request).decode() + if not authorization or not authorization.startswith("Bearer "): + raise ParseError("Missing Bearer header.") + + token = authorization.split(" ")[1] + if token == "": + raise ParseError("Missing FXA Token after 'Bearer'.") + + try: + fxa_uid = get_fxa_uid_from_oauth_token(token, use_cache=False) + except AuthenticationFailed as e: + # AuthenticationFailed exception returns 403 instead of 401 because we are not + # using the proper config that comes with the authentication_classes. See: + # https://www.django-rest-framework.org/api-guide/authentication/#custom-authentication + if isinstance(e.detail, ErrorDetail): + return Response(data={"detail": e.detail.title()}, status=e.status_code) + else: + return Response(data={"detail": e.get_full_details()}, status=e.status_code) + status_code = 201 + + try: + sa = SocialAccount.objects.get(uid=fxa_uid, provider="fxa") + status_code = 202 + except SocialAccount.DoesNotExist: + # User does not exist, create a new Relay user + fxa_profile_resp = requests.get( + FXA_PROFILE_URL, + headers={"Authorization": f"Bearer {token}"}, + timeout=settings.FXA_REQUESTS_TIMEOUT_SECONDS, + ) + if not (fxa_profile_resp.ok and fxa_profile_resp.content): + logger.error( + "terms_accepted_user: bad account profile response", + extra={ + "status_code": fxa_profile_resp.status_code, + "content": fxa_profile_resp.content, + }, + ) + return Response( + data={"detail": "Did not receive a 200 response for account profile."}, + status=500, + ) + + # This is not exactly the request object that FirefoxAccountsProvider expects, + # but it has all of the necessary attributes to initialize the Provider + provider = get_social_adapter().get_provider(request, "fxa") + # This may not save the new user that was created + # https://github.com/pennersr/django-allauth/blob/77368a84903d32283f07a260819893ec15df78fb/allauth/socialaccount/providers/base/provider.py#L44 + social_login = provider.sociallogin_from_response( + request, fxa_profile_resp.json() + ) + # Complete social login is called by callback, see + # https://github.com/pennersr/django-allauth/blob/77368a84903d32283f07a260819893ec15df78fb/allauth/socialaccount/providers/oauth/views.py#L118 + # for what we are mimicking to create new SocialAccount, User, and Profile for + # the new Relay user from Firefox Since this is a Resource Provider/Server flow + # and are NOT a Relying Party (RP) of FXA No social token information is stored + # (no Social Token object created). + try: + complete_social_login(request, social_login) + # complete_social_login writes ['account_verified_email', 'user_created', + # '_auth_user_id', '_auth_user_backend', '_auth_user_hash'] on + # request.session which sets the cookie because complete_social_login does + # the "login" The user did not actually log in, logout to clear the session + if request.user.is_authenticated: + get_account_adapter(request).logout(request) + except NoReverseMatch as e: + # TODO: use this logging to fix the underlying issue + # https://mozilla-hub.atlassian.net/browse/MPP-3473 + if "socialaccount_signup" in e.args[0]: + logger.error( + "socialaccount_signup_error", + extra={ + "exception": str(e), + "fxa_uid": fxa_uid, + "social_login_state": social_login.state, + }, + ) + return Response(status=500) + raise e + sa = SocialAccount.objects.get(uid=fxa_uid, provider="fxa") + # Indicate profile was created from the resource flow + profile = sa.user.profile + profile.created_by = "firefox_resource" + profile.save() + info_logger.info( + "terms_accepted_user", + extra={"social_account": sa.uid, "status_code": status_code}, + ) + return Response(status=status_code) + + +@api_view(["POST"]) +@permission_classes([HasValidFxaToken, IsNewUser | IsActive]) +@authentication_classes([FxaTokenAuthentication]) +def terms_accepted_user_2025(request: Request) -> Response: + """ + Create a Relay user from an FXA token. + + See API Auth doc for details: + + https://github.com/mozilla/fx-private-relay/blob/main/docs/api_auth.md#firefox-oauth-token-authentication-and-accept-terms-of-service + """ user = request.user introspect_response = request.auth diff --git a/privaterelay/settings.py b/privaterelay/settings.py index d010563624..4ce99bc88b 100644 --- a/privaterelay/settings.py +++ b/privaterelay/settings.py @@ -631,6 +631,9 @@ def set_index_cache_control_headers( "https://api.accounts.firefox.com/v1", ) FXA_SUPPORT_URL = config("FXA_SUPPORT_URL", f"{FXA_BASE_ORIGIN}/support/") +FXA_TOKEN_AUTH_VERSION = config( + "FXA_TOKEN_AUTH_VERSION", "2024", cast=Choices(["2024", "2025"], cast=str) +) LOGGING = { "version": 1,