privaterelay/views.py (298 lines of code) (raw):
import json
import logging
from collections.abc import Iterable
from datetime import UTC, datetime
from functools import cache
from hashlib import sha256
from typing import Any, TypedDict
from django.apps import apps
from django.conf import settings
from django.db import IntegrityError, transaction
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
import jwt
import sentry_sdk
from allauth.socialaccount.models import SocialAccount, SocialApp
from allauth.socialaccount.providers.fxa.views import FirefoxAccountsOAuth2Adapter
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
from markus.utils import generate_tag
from oauthlib.oauth2.rfc6749.errors import CustomOAuth2Error
from rest_framework.decorators import api_view, schema
from emails.models import DomainAddress, RelayAddress
from emails.utils import incr_if_enabled
from .apps import PrivateRelayConfig
from .exceptions import CannotMakeSubdomainException
from .fxa_utils import NoSocialToken, _get_oauth2_session
from .validators import valid_available_subdomain
FXA_PROFILE_CHANGE_EVENT = "https://schemas.accounts.firefox.com/event/profile-change"
FXA_SUBSCRIPTION_CHANGE_EVENT = (
"https://schemas.accounts.firefox.com/event/subscription-state-change"
)
FXA_DELETE_EVENT = "https://schemas.accounts.firefox.com/event/delete-user"
PROFILE_EVENTS = [FXA_PROFILE_CHANGE_EVENT, FXA_SUBSCRIPTION_CHANGE_EVENT]
logger = logging.getLogger("events")
info_logger = logging.getLogger("eventsinfo")
@cache
def _get_fxa(request):
return request.user.socialaccount_set.filter(provider="fxa").first()
@api_view()
@schema(None)
@require_http_methods(["GET"])
def profile_refresh(request):
if not request.user or request.user.is_anonymous:
return redirect(reverse("fxa_login"))
profile = request.user.profile
fxa = _get_fxa(request)
update_fxa(fxa)
if "clicked-purchase" in request.COOKIES and profile.has_premium:
event = "user_purchased_premium"
incr_if_enabled(event, 1)
return JsonResponse({})
@api_view(["POST", "GET"])
@schema(None)
@require_http_methods(["POST", "GET"])
def profile_subdomain(request):
if not request.user or request.user.is_anonymous:
return redirect(reverse("fxa_login"))
profile = request.user.profile
if not profile.has_premium:
raise CannotMakeSubdomainException("error-premium-check-subdomain")
try:
if request.method == "GET":
subdomain = request.GET.get("subdomain", None)
valid_available_subdomain(subdomain)
return JsonResponse({"available": True})
else:
subdomain = request.POST.get("subdomain", None)
profile.add_subdomain(subdomain)
return JsonResponse(
{"status": "Accepted", "message": "success-subdomain-registered"},
status=202,
)
except CannotMakeSubdomainException as e:
return JsonResponse({"message": e.message, "subdomain": subdomain}, status=400)
@csrf_exempt
@require_http_methods(["POST"])
def metrics_event(request: HttpRequest) -> JsonResponse:
"""
Handle metrics events from the Relay extension.
This used to forward data to Google Analytics, but was not updated for GA4.
Now it logs the information and updates statsd counters.
"""
try:
request_data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"msg": "Could not decode JSON"}, status=415)
if "ga_uuid" not in request_data:
return JsonResponse({"msg": "No GA uuid found"}, status=404)
event_data = {
"ga_uuid_hash": sha256(request_data["ga_uuid"].encode()).hexdigest()[:16],
"category": request_data.get("category", None),
"action": request_data.get("action", None),
"label": request_data.get("label", None),
"value": request_data.get("value", None),
"browser": request_data.get("browser", None), # dimension5 in GA
"source": request_data.get("dimension7", "website"),
}
info_logger.info("metrics_event", extra=event_data)
tags = [
generate_tag(key, val)
for key, val in event_data.items()
if val is not None and key != "ga_uuid_hash"
]
incr_if_enabled("metrics_event", tags=tags)
return JsonResponse({"msg": "OK"}, status=200)
@csrf_exempt
def fxa_rp_events(request: HttpRequest) -> HttpResponse:
req_jwt = _parse_jwt_from_request(request)
authentic_jwt = _authenticate_fxa_jwt(req_jwt)
event_keys = _get_event_keys_from_jwt(authentic_jwt)
try:
social_account = _get_account_from_jwt(authentic_jwt)
except SocialAccount.DoesNotExist:
# Don't error, or FXA will retry
return HttpResponse("202 Accepted", status=202)
for event_key in event_keys:
if event_key in PROFILE_EVENTS:
if settings.DEBUG:
info_logger.info(
"fxa_profile_update",
extra={
"jwt": authentic_jwt,
"event_key": event_key,
},
)
update_fxa(social_account, authentic_jwt, event_key)
if event_key == FXA_DELETE_EVENT:
_handle_fxa_delete(authentic_jwt, social_account, event_key)
return HttpResponse("200 OK", status=200)
def _parse_jwt_from_request(request: HttpRequest) -> str:
request_auth = request.headers["Authorization"]
return request_auth.split("Bearer ")[1]
def fxa_verifying_keys(reload: bool = False) -> list[dict[str, Any]]:
"""Get list of FxA verifying (public) keys."""
private_relay_config = apps.get_app_config("privaterelay")
if not isinstance(private_relay_config, PrivateRelayConfig):
raise TypeError("private_relay_config must be PrivateRelayConfig")
if reload:
private_relay_config.ready()
return private_relay_config.fxa_verifying_keys
def fxa_social_app(reload: bool = False) -> SocialApp:
"""Get FxA SocialApp from app config or DB."""
private_relay_config = apps.get_app_config("privaterelay")
if not isinstance(private_relay_config, PrivateRelayConfig):
raise TypeError("private_relay_config must be PrivateRelayConfig")
if reload:
private_relay_config.ready()
return private_relay_config.fxa_social_app
class FxAEvent(TypedDict):
"""
FxA Security Event Token (SET) payload, sent to relying parties.
See:
https://github.com/mozilla/fxa/tree/main/packages/fxa-event-broker
https://www.rfc-editor.org/rfc/rfc8417 (Security Event Token)
"""
iss: str # Issuer, https://accounts.firefox.com/
sub: str # Subject, FxA user ID
aud: str # Audience, Relay's client ID
iat: int # Creation time, timestamp
jti: str # JWT ID, unique for this SET
events: dict[str, dict[str, Any]] # Event data
def _authenticate_fxa_jwt(req_jwt: str) -> FxAEvent:
authentic_jwt = _verify_jwt_with_fxa_key(req_jwt, fxa_verifying_keys())
if not authentic_jwt:
# FXA key may be old? re-fetch FXA keys and try again
authentic_jwt = _verify_jwt_with_fxa_key(
req_jwt, fxa_verifying_keys(reload=True)
)
if not authentic_jwt:
raise Exception("Could not authenticate JWT with FXA key.")
return authentic_jwt
def _verify_jwt_with_fxa_key(
req_jwt: str, verifying_keys: list[dict[str, Any]]
) -> FxAEvent | None:
if not verifying_keys:
raise Exception("FXA verifying keys are not available.")
social_app = fxa_social_app()
if not social_app:
raise Exception("FXA SocialApp is not available.")
if not isinstance(social_app, SocialApp):
raise TypeError("social_app must be SocialApp")
for verifying_key in verifying_keys:
if verifying_key["alg"] == "RS256":
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(verifying_key))
if not isinstance(public_key, RSAPublicKey):
raise TypeError("public_key must be RSAPublicKey")
try:
security_event = jwt.decode(
req_jwt,
public_key,
audience=social_app.client_id,
algorithms=["RS256"],
leeway=5, # allow iat to be slightly in future, for clock skew
)
except jwt.ImmatureSignatureError:
# Issue 2738: Log age of iat, if present
claims = jwt.decode(
req_jwt,
public_key,
algorithms=["RS256"],
options={"verify_signature": False},
)
iat = claims.get("iat")
iat_age = None
if iat:
iat_age = round(datetime.now(tz=UTC).timestamp() - iat, 3)
info_logger.warning(
"fxa_rp_event.future_iat", extra={"iat": iat, "iat_age_s": iat_age}
)
raise
return FxAEvent(
iss=security_event["iss"],
sub=security_event["sub"],
aud=security_event["aud"],
iat=security_event["iat"],
jti=security_event["jti"],
events=security_event["events"],
)
return None
def _get_account_from_jwt(authentic_jwt: FxAEvent) -> SocialAccount:
social_account_uid = authentic_jwt["sub"]
return SocialAccount.objects.get(uid=social_account_uid, provider="fxa")
def _get_event_keys_from_jwt(authentic_jwt: FxAEvent) -> Iterable[str]:
return authentic_jwt["events"].keys()
def update_fxa(
social_account: SocialAccount,
authentic_jwt: FxAEvent | None = None,
event_key: str | None = None,
) -> HttpResponse:
try:
client = _get_oauth2_session(social_account)
except NoSocialToken as e:
sentry_sdk.capture_exception(e)
return HttpResponse("202 Accepted", status=202)
# TODO: more graceful handling of profile fetch failures
try:
resp = client.get(FirefoxAccountsOAuth2Adapter.profile_url)
except CustomOAuth2Error as e:
sentry_sdk.capture_exception(e)
return HttpResponse("202 Accepted", status=202)
extra_data = resp.json()
try:
new_email = extra_data["email"]
except KeyError as e:
sentry_sdk.capture_exception(e)
return HttpResponse("202 Accepted", status=202)
if authentic_jwt and event_key:
info_logger.info(
"fxa_rp_event",
extra={
"fxa_uid": authentic_jwt["sub"],
"event_key": event_key,
"real_address": sha256(new_email.encode("utf-8")).hexdigest(),
},
)
return _update_all_data(social_account, extra_data, new_email)
def _update_all_data(
social_account: SocialAccount, extra_data: dict[str, Any], new_email: str
) -> HttpResponse:
try:
profile = social_account.user.profile
had_premium = profile.has_premium
had_phone = profile.has_phone
with transaction.atomic():
social_account.extra_data = extra_data
social_account.save()
profile = social_account.user.profile
now_has_premium = profile.has_premium
newly_premium = not had_premium and now_has_premium
no_longer_premium = had_premium and not now_has_premium
if newly_premium:
incr_if_enabled("user_purchased_premium", 1)
profile.date_subscribed = datetime.now(UTC)
profile.save()
if no_longer_premium:
incr_if_enabled("user_has_downgraded", 1)
now_has_phone = profile.has_phone
newly_phone = not had_phone and now_has_phone
no_longer_phone = had_phone and not now_has_phone
if newly_phone:
incr_if_enabled("user_purchased_phone", 1)
profile.date_subscribed_phone = datetime.now(UTC)
profile.date_phone_subscription_reset = datetime.now(UTC)
profile.save()
if no_longer_phone:
incr_if_enabled("user_has_dropped_phone", 1)
social_account.user.email = new_email
social_account.user.save()
email_address_record = social_account.user.emailaddress_set.first()
if email_address_record:
email_address_record.email = new_email
email_address_record.save()
else:
social_account.user.emailaddress_set.create(email=new_email)
return HttpResponse("202 Accepted", status=202)
except IntegrityError as e:
sentry_sdk.capture_exception(e)
return HttpResponse("Conflict", status=409)
def _handle_fxa_delete(
authentic_jwt: FxAEvent, social_account: SocialAccount, event_key: str
) -> None:
# Using for loops here because QuerySet.delete() does a bulk delete which does
# not call the model delete() methods that create DeletedAddress records
for relay_address in RelayAddress.objects.filter(user=social_account.user):
relay_address.delete()
for domain_address in DomainAddress.objects.filter(user=social_account.user):
domain_address.delete()
social_account.user.delete()
info_logger.info(
"fxa_rp_event",
extra={
"fxa_uid": authentic_jwt["sub"],
"event_key": event_key,
},
)