• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / 979aba2a-67a1-4a23-9dda-524b52095950

24 May 2024 08:51PM CUT coverage: 84.423% (+0.02%) from 84.4%
979aba2a-67a1-4a23-9dda-524b52095950

push

circleci

web-flow
Merge pull request #4725 from mozilla/MPP-3767-Remove-SocialAccount-does-not-exist-log

Remove Sentry logger for SocialAccount does not exist

3619 of 4747 branches covered (76.24%)

Branch coverage included in aggregate %.

0 of 1 new or added line in 1 file covered. (0.0%)

2 existing lines in 2 files now uncovered.

14813 of 17086 relevant lines covered (86.7%)

10.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.47
/privaterelay/views.py
1
import json
1✔
2
import logging
1✔
3
import threading
1✔
4
from collections.abc import Iterable
1✔
5
from datetime import UTC, datetime
1✔
6
from functools import cache
1✔
7
from hashlib import sha256
1✔
8
from typing import Any, TypedDict
1✔
9

10
from django.apps import apps
1✔
11
from django.conf import settings
1✔
12
from django.db import IntegrityError, transaction
1✔
13
from django.http import HttpRequest, HttpResponse, JsonResponse
1✔
14
from django.shortcuts import redirect
1✔
15
from django.urls import reverse
1✔
16
from django.views.decorators.csrf import csrf_exempt
1✔
17
from django.views.decorators.http import require_http_methods
1✔
18

19
import jwt
1✔
20
import sentry_sdk
1✔
21
from allauth.socialaccount.models import SocialAccount, SocialApp
1✔
22
from allauth.socialaccount.providers.fxa.views import FirefoxAccountsOAuth2Adapter
1✔
23
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
1✔
24
from google_measurement_protocol import event, report
1✔
25
from oauthlib.oauth2.rfc6749.errors import CustomOAuth2Error
1✔
26
from rest_framework.decorators import api_view, schema
1✔
27

28
# from silk.profiling.profiler import silk_profile
29
from emails.models import (
1✔
30
    CannotMakeSubdomainException,
31
    DomainAddress,
32
    RelayAddress,
33
    valid_available_subdomain,
34
)
35
from emails.utils import incr_if_enabled
1✔
36

37
from .apps import PrivateRelayConfig
1✔
38
from .fxa_utils import NoSocialToken, _get_oauth2_session
1✔
39

40
FXA_PROFILE_CHANGE_EVENT = "https://schemas.accounts.firefox.com/event/profile-change"
1✔
41
FXA_SUBSCRIPTION_CHANGE_EVENT = (
1✔
42
    "https://schemas.accounts.firefox.com/event/subscription-state-change"
43
)
44
FXA_DELETE_EVENT = "https://schemas.accounts.firefox.com/event/delete-user"
1✔
45
PROFILE_EVENTS = [FXA_PROFILE_CHANGE_EVENT, FXA_SUBSCRIPTION_CHANGE_EVENT]
1✔
46

47
logger = logging.getLogger("events")
1✔
48
info_logger = logging.getLogger("eventsinfo")
1✔
49

50

51
@cache
1✔
52
def _get_fxa(request):
1✔
53
    return request.user.socialaccount_set.filter(provider="fxa").first()
×
54

55

56
@api_view()
1✔
57
@schema(None)
1✔
58
@require_http_methods(["GET"])
1✔
59
def profile_refresh(request):
1✔
60
    if not request.user or request.user.is_anonymous:
×
61
        return redirect(reverse("fxa_login"))
×
62
    profile = request.user.profile
×
63

64
    fxa = _get_fxa(request)
×
65
    update_fxa(fxa)
×
66
    if "clicked-purchase" in request.COOKIES and profile.has_premium:
×
67
        event = "user_purchased_premium"
×
68
        incr_if_enabled(event, 1)
×
69

70
    return JsonResponse({})
×
71

72

73
@api_view(["POST", "GET"])
1✔
74
@schema(None)
1✔
75
@require_http_methods(["POST", "GET"])
1✔
76
def profile_subdomain(request):
1✔
77
    if not request.user or request.user.is_anonymous:
×
78
        return redirect(reverse("fxa_login"))
×
79
    profile = request.user.profile
×
80
    if not profile.has_premium:
×
81
        raise CannotMakeSubdomainException("error-premium-check-subdomain")
×
82
    try:
×
83
        if request.method == "GET":
×
84
            subdomain = request.GET.get("subdomain", None)
×
85
            available = valid_available_subdomain(subdomain)
×
86
            return JsonResponse({"available": available})
×
87
        else:
88
            subdomain = request.POST.get("subdomain", None)
×
89
            profile.add_subdomain(subdomain)
×
90
            return JsonResponse(
×
91
                {"status": "Accepted", "message": "success-subdomain-registered"},
92
                status=202,
93
            )
94
    except CannotMakeSubdomainException as e:
×
95
        return JsonResponse({"message": e.message, "subdomain": subdomain}, status=400)
×
96

97

98
def send_ga_ping(ga_id: str, ga_uuid: str, data: Any) -> None:
1✔
99
    try:
1✔
100
        report(ga_id, ga_uuid, data)
1✔
101
    except Exception as e:
×
102
        logger.error("metrics_event", extra={"error": e})
×
103

104

105
@csrf_exempt
1✔
106
@require_http_methods(["POST"])
1✔
107
def metrics_event(request: HttpRequest) -> JsonResponse:
1✔
108
    try:
1✔
109
        request_data = json.loads(request.body)
1✔
110
    except json.JSONDecodeError:
1✔
111
        return JsonResponse({"msg": "Could not decode JSON"}, status=415)
1✔
112
    if "ga_uuid" not in request_data:
1✔
113
        return JsonResponse({"msg": "No GA uuid found"}, status=404)
1✔
114
    # "dimension5" is a Google Analytics-specific variable to track a custom dimension,
115
    # used to determine which browser vendor the add-on is using: Firefox or Chrome
116
    # "dimension7" is a Google Analytics-specific variable to track a custom dimension,
117
    # used to determine where the ping is coming from: website (default), add-on or app
118
    event_data = event(
1✔
119
        request_data.get("category", None),
120
        request_data.get("action", None),
121
        request_data.get("label", None),
122
        request_data.get("value", None),
123
        dimension5=request_data.get("dimension5", None),
124
        dimension7=request_data.get("dimension7", "website"),
125
    )
126
    t = threading.Thread(
1✔
127
        target=send_ga_ping,
128
        args=[settings.GOOGLE_ANALYTICS_ID, request_data.get("ga_uuid"), event_data],
129
        daemon=True,
130
    )
131
    t.start()
1✔
132
    return JsonResponse({"msg": "OK"}, status=200)
1✔
133

134

135
@csrf_exempt
1✔
136
def fxa_rp_events(request: HttpRequest) -> HttpResponse:
1✔
137
    req_jwt = _parse_jwt_from_request(request)
1✔
138
    authentic_jwt = _authenticate_fxa_jwt(req_jwt)
1✔
139
    event_keys = _get_event_keys_from_jwt(authentic_jwt)
1✔
140
    try:
1✔
141
        social_account = _get_account_from_jwt(authentic_jwt)
1✔
NEW
142
    except SocialAccount.DoesNotExist:
×
143
        # Don't error, or FXA will retry
UNCOV
144
        return HttpResponse("202 Accepted", status=202)
×
145

146
    for event_key in event_keys:
1✔
147
        if event_key in PROFILE_EVENTS:
1✔
148
            if settings.DEBUG:
1!
149
                info_logger.info(
×
150
                    "fxa_profile_update",
151
                    extra={
152
                        "jwt": authentic_jwt,
153
                        "event_key": event_key,
154
                    },
155
                )
156
            update_fxa(social_account, authentic_jwt, event_key)
1✔
157
        if event_key == FXA_DELETE_EVENT:
1✔
158
            _handle_fxa_delete(authentic_jwt, social_account, event_key)
1✔
159
    return HttpResponse("200 OK", status=200)
1✔
160

161

162
def _parse_jwt_from_request(request: HttpRequest) -> str:
1✔
163
    request_auth = request.headers["Authorization"]
1✔
164
    return request_auth.split("Bearer ")[1]
1✔
165

166

167
def fxa_verifying_keys(reload: bool = False) -> list[dict[str, Any]]:
1✔
168
    """Get list of FxA verifying (public) keys."""
169
    private_relay_config = apps.get_app_config("privaterelay")
1✔
170
    if not isinstance(private_relay_config, PrivateRelayConfig):
1!
171
        raise TypeError("private_relay_config must be PrivateRelayConfig")
×
172
    if reload:
1✔
173
        private_relay_config.ready()
1✔
174
    return private_relay_config.fxa_verifying_keys
1✔
175

176

177
def fxa_social_app(reload: bool = False) -> SocialApp:
1✔
178
    """Get FxA SocialApp from app config or DB."""
179
    private_relay_config = apps.get_app_config("privaterelay")
1✔
180
    if not isinstance(private_relay_config, PrivateRelayConfig):
1!
181
        raise TypeError("private_relay_config must be PrivateRelayConfig")
×
182
    if reload:
1!
183
        private_relay_config.ready()
×
184
    return private_relay_config.fxa_social_app
1✔
185

186

187
class FxAEvent(TypedDict):
1✔
188
    """
189
    FxA Security Event Token (SET) payload, sent to relying parties.
190

191
    See:
192
    https://github.com/mozilla/fxa/tree/main/packages/fxa-event-broker
193
    https://www.rfc-editor.org/rfc/rfc8417 (Security Event Token)
194
    """
195

196
    iss: str  # Issuer, https://accounts.firefox.com/
1✔
197
    sub: str  # Subject, FxA user ID
1✔
198
    aud: str  # Audience, Relay's client ID
1✔
199
    iat: int  # Creation time, timestamp
1✔
200
    jti: str  # JWT ID, unique for this SET
1✔
201
    events: dict[str, dict[str, Any]]  # Event data
1✔
202

203

204
def _authenticate_fxa_jwt(req_jwt: str) -> FxAEvent:
1✔
205
    authentic_jwt = _verify_jwt_with_fxa_key(req_jwt, fxa_verifying_keys())
1✔
206

207
    if not authentic_jwt:
1!
208
        # FXA key may be old? re-fetch FXA keys and try again
209
        authentic_jwt = _verify_jwt_with_fxa_key(
×
210
            req_jwt, fxa_verifying_keys(reload=True)
211
        )
212
        if not authentic_jwt:
×
213
            raise Exception("Could not authenticate JWT with FXA key.")
×
214

215
    return authentic_jwt
1✔
216

217

218
def _verify_jwt_with_fxa_key(
1✔
219
    req_jwt: str, verifying_keys: list[dict[str, Any]]
220
) -> FxAEvent | None:
221
    if not verifying_keys:
1!
222
        raise Exception("FXA verifying keys are not available.")
×
223
    social_app = fxa_social_app()
1✔
224
    if not social_app:
1!
225
        raise Exception("FXA SocialApp is not available.")
×
226
    if not isinstance(social_app, SocialApp):
1!
227
        raise TypeError("social_app must be SocialApp")
×
228
    for verifying_key in verifying_keys:
1!
229
        if verifying_key["alg"] == "RS256":
1!
230
            public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(verifying_key))
1✔
231
            if not isinstance(public_key, RSAPublicKey):
1!
232
                raise TypeError("public_key must be RSAPublicKey")
×
233
            try:
1✔
234
                security_event = jwt.decode(
1✔
235
                    req_jwt,
236
                    public_key,
237
                    audience=social_app.client_id,
238
                    algorithms=["RS256"],
239
                    leeway=5,  # allow iat to be slightly in future, for clock skew
240
                )
241
            except jwt.ImmatureSignatureError:
1✔
242
                # Issue 2738: Log age of iat, if present
243
                claims = jwt.decode(
1✔
244
                    req_jwt,
245
                    public_key,
246
                    algorithms=["RS256"],
247
                    options={"verify_signature": False},
248
                )
249
                iat = claims.get("iat")
1✔
250
                iat_age = None
1✔
251
                if iat:
1!
252
                    iat_age = round(datetime.now(tz=UTC).timestamp() - iat, 3)
1✔
253
                info_logger.warning(
1✔
254
                    "fxa_rp_event.future_iat", extra={"iat": iat, "iat_age_s": iat_age}
255
                )
256
                raise
1✔
257
            return FxAEvent(
1✔
258
                iss=security_event["iss"],
259
                sub=security_event["sub"],
260
                aud=security_event["aud"],
261
                iat=security_event["iat"],
262
                jti=security_event["jti"],
263
                events=security_event["events"],
264
            )
265
    return None
×
266

267

268
def _get_account_from_jwt(authentic_jwt: FxAEvent) -> SocialAccount:
1✔
269
    social_account_uid = authentic_jwt["sub"]
1✔
270
    return SocialAccount.objects.get(uid=social_account_uid, provider="fxa")
1✔
271

272

273
def _get_event_keys_from_jwt(authentic_jwt: FxAEvent) -> Iterable[str]:
1✔
274
    return authentic_jwt["events"].keys()
1✔
275

276

277
def update_fxa(
1✔
278
    social_account: SocialAccount,
279
    authentic_jwt: FxAEvent | None = None,
280
    event_key: str | None = None,
281
) -> HttpResponse:
282
    try:
1✔
283
        client = _get_oauth2_session(social_account)
1✔
284
    except NoSocialToken as e:
×
285
        sentry_sdk.capture_exception(e)
×
286
        return HttpResponse("202 Accepted", status=202)
×
287

288
    # TODO: more graceful handling of profile fetch failures
289
    try:
1✔
290
        resp = client.get(FirefoxAccountsOAuth2Adapter.profile_url)
1✔
291
    except CustomOAuth2Error as e:
×
292
        sentry_sdk.capture_exception(e)
×
293
        return HttpResponse("202 Accepted", status=202)
×
294

295
    extra_data = resp.json()
1✔
296

297
    try:
1✔
298
        new_email = extra_data["email"]
1✔
299
    except KeyError as e:
×
300
        sentry_sdk.capture_exception(e)
×
301
        return HttpResponse("202 Accepted", status=202)
×
302

303
    if authentic_jwt and event_key:
1!
304
        info_logger.info(
1✔
305
            "fxa_rp_event",
306
            extra={
307
                "fxa_uid": authentic_jwt["sub"],
308
                "event_key": event_key,
309
                "real_address": sha256(new_email.encode("utf-8")).hexdigest(),
310
            },
311
        )
312

313
    return _update_all_data(social_account, extra_data, new_email)
1✔
314

315

316
def _update_all_data(
1✔
317
    social_account: SocialAccount, extra_data: dict[str, Any], new_email: str
318
) -> HttpResponse:
319
    try:
1✔
320
        profile = social_account.user.profile
1✔
321
        had_premium = profile.has_premium
1✔
322
        had_phone = profile.has_phone
1✔
323
        with transaction.atomic():
1✔
324
            social_account.extra_data = extra_data
1✔
325
            social_account.save()
1✔
326
            profile = social_account.user.profile
1✔
327
            now_has_premium = profile.has_premium
1✔
328
            newly_premium = not had_premium and now_has_premium
1✔
329
            no_longer_premium = had_premium and not now_has_premium
1✔
330
            if newly_premium:
1✔
331
                incr_if_enabled("user_purchased_premium", 1)
1✔
332
                profile.date_subscribed = datetime.now(UTC)
1✔
333
                profile.save()
1✔
334
            if no_longer_premium:
1!
335
                incr_if_enabled("user_has_downgraded", 1)
×
336
            now_has_phone = profile.has_phone
1✔
337
            newly_phone = not had_phone and now_has_phone
1✔
338
            no_longer_phone = had_phone and not now_has_phone
1✔
339
            if newly_phone:
1✔
340
                incr_if_enabled("user_purchased_phone", 1)
1✔
341
                profile.date_subscribed_phone = datetime.now(UTC)
1✔
342
                profile.date_phone_subscription_reset = datetime.now(UTC)
1✔
343
                profile.save()
1✔
344
            if no_longer_phone:
1!
345
                incr_if_enabled("user_has_dropped_phone", 1)
×
346
            social_account.user.email = new_email
1✔
347
            social_account.user.save()
1✔
348
            email_address_record = social_account.user.emailaddress_set.first()
1✔
349
            if email_address_record:
1✔
350
                email_address_record.email = new_email
1✔
351
                email_address_record.save()
1✔
352
            else:
353
                social_account.user.emailaddress_set.create(email=new_email)
1✔
354
            return HttpResponse("202 Accepted", status=202)
1✔
355
    except IntegrityError as e:
1✔
356
        sentry_sdk.capture_exception(e)
1✔
357
        return HttpResponse("Conflict", status=409)
1✔
358

359

360
def _handle_fxa_delete(
1✔
361
    authentic_jwt: FxAEvent, social_account: SocialAccount, event_key: str
362
) -> None:
363
    # Using for loops here because QuerySet.delete() does a bulk delete which does
364
    # not call the model delete() methods that create DeletedAddress records
365
    for relay_address in RelayAddress.objects.filter(user=social_account.user):
1✔
366
        relay_address.delete()
1✔
367
    for domain_address in DomainAddress.objects.filter(user=social_account.user):
1✔
368
        domain_address.delete()
1✔
369

370
    social_account.user.delete()
1✔
371
    info_logger.info(
1✔
372
        "fxa_rp_event",
373
        extra={
374
            "fxa_uid": authentic_jwt["sub"],
375
            "event_key": event_key,
376
        },
377
    )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc