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

mozilla / fx-private-relay / c6e1a27a-3644-4ace-9c59-b667484a3ad6

16 Jan 2025 08:04PM CUT coverage: 85.389% (+0.3%) from 85.088%
c6e1a27a-3644-4ace-9c59-b667484a3ad6

Pull #5272

circleci

jwhitlock
Emit timing metric for profile fetch
Pull Request #5272: WIP MPP-3505: RewriteAccount Bearer Token Authentication

2502 of 3627 branches covered (68.98%)

Branch coverage included in aggregate %.

841 of 842 new or added lines in 6 files covered. (99.88%)

2 existing lines in 1 file now uncovered.

17333 of 19602 relevant lines covered (88.42%)

9.78 hits per line

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

90.21
/api/views/privaterelay.py
1
from logging import getLogger
1✔
2
from typing import Any, Literal
1✔
3

4
from django.conf import settings
1✔
5
from django.contrib.auth.models import User
1✔
6
from django.db import IntegrityError
1✔
7
from django.db.models.query import QuerySet
1✔
8
from django.urls.exceptions import NoReverseMatch
1✔
9

10
import requests
1✔
11
from allauth.account.adapter import get_adapter as get_account_adapter
1✔
12
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
1✔
13
from allauth.socialaccount.helpers import complete_social_login
1✔
14
from allauth.socialaccount.models import SocialAccount
1✔
15
from codetiming import Timer
1✔
16
from django_filters.rest_framework import FilterSet
1✔
17
from drf_spectacular.utils import (
1✔
18
    OpenApiExample,
19
    OpenApiParameter,
20
    OpenApiRequest,
21
    OpenApiResponse,
22
    extend_schema,
23
)
24
from markus.utils import generate_tag
1✔
25
from rest_framework.decorators import (
1✔
26
    api_view,
27
    authentication_classes,
28
    permission_classes,
29
)
30
from rest_framework.permissions import AllowAny, IsAuthenticated
1✔
31
from rest_framework.request import Request
1✔
32
from rest_framework.response import Response
1✔
33
from rest_framework.status import HTTP_201_CREATED, HTTP_400_BAD_REQUEST
1✔
34
from rest_framework.viewsets import ModelViewSet
1✔
35
from waffle import get_waffle_flag_model
1✔
36
from waffle.models import Sample, Switch
1✔
37

38
from emails.utils import histogram_if_enabled, incr_if_enabled
1✔
39
from privaterelay.models import Profile
1✔
40
from privaterelay.plans import (
1✔
41
    get_bundle_country_language_mapping,
42
    get_phone_country_language_mapping,
43
    get_premium_country_language_mapping,
44
)
45
from privaterelay.utils import get_countries_info_from_request_and_mapping
1✔
46

47
from ..authentication import FxaTokenAuthentication, IntrospectionResponse
1✔
48
from ..permissions import CanManageFlags, HasValidFxaToken, IsActive, IsNewUser, IsOwner
1✔
49
from ..serializers.privaterelay import (
1✔
50
    FlagSerializer,
51
    ProfileSerializer,
52
    UserSerializer,
53
    WebcompatIssueSerializer,
54
)
55

56
logger = getLogger("events")
1✔
57
info_logger = getLogger("eventsinfo")
1✔
58
FXA_PROFILE_URL = (
1✔
59
    f"{settings.SOCIALACCOUNT_PROVIDERS['fxa']['PROFILE_ENDPOINT']}/profile"
60
)
61

62

63
class FlagFilter(FilterSet):
1✔
64
    class Meta:
1✔
65
        model = get_waffle_flag_model()
1✔
66
        fields = [
1✔
67
            "name",
68
            "everyone",
69
            # "users",
70
            # read-only
71
            "id",
72
        ]
73

74

75
@extend_schema(tags=["privaterelay"])
1✔
76
class FlagViewSet(ModelViewSet):
1✔
77
    """Feature flags."""
78

79
    serializer_class = FlagSerializer
1✔
80
    permission_classes = [IsAuthenticated, CanManageFlags]
1✔
81
    filterset_class = FlagFilter
1✔
82
    http_method_names = ["get", "post", "head", "patch"]
1✔
83

84
    def get_queryset(self):
1✔
85
        flags = get_waffle_flag_model().objects
1✔
86
        return flags
1✔
87

88

89
@extend_schema(tags=["privaterelay"])
1✔
90
class ProfileViewSet(ModelViewSet):
1✔
91
    """Relay user extended profile data."""
92

93
    serializer_class = ProfileSerializer
1✔
94
    permission_classes = [IsAuthenticated, IsOwner]
1✔
95
    http_method_names = ["get", "post", "head", "put", "patch"]
1✔
96

97
    def get_queryset(self) -> QuerySet[Profile]:
1✔
98
        if isinstance(self.request.user, User):
1✔
99
            return Profile.objects.filter(user=self.request.user)
1✔
100
        return Profile.objects.none()
1✔
101

102

103
@extend_schema(tags=["privaterelay"])
1✔
104
class UserViewSet(ModelViewSet):
1✔
105
    """Relay user data stored in Django user model."""
106

107
    serializer_class = UserSerializer
1✔
108
    permission_classes = [IsAuthenticated, IsOwner]
1✔
109
    http_method_names = ["get", "head"]
1✔
110

111
    def get_queryset(self) -> QuerySet[User]:
1✔
112
        if isinstance(self.request.user, User):
1!
113
            return User.objects.filter(id=self.request.user.id)
×
114
        return User.objects.none()
1✔
115

116

117
@permission_classes([IsAuthenticated])
1✔
118
@extend_schema(
1✔
119
    tags=["privaterelay"],
120
    request=WebcompatIssueSerializer,
121
    examples=[
122
        OpenApiExample(
123
            "mask not accepted",
124
            {
125
                "issue_on_domain": "https://accounts.firefox.com",
126
                "user_agent": "Firefox",
127
                "email_mask_not_accepted": True,
128
                "add_on_visual_issue": False,
129
                "email_not_received": False,
130
                "other_issue": "",
131
            },
132
        )
133
    ],
134
    responses={
135
        "201": OpenApiResponse(description="Report was submitted"),
136
        "400": OpenApiResponse(description="Report was rejected due to errors."),
137
        "401": OpenApiResponse(description="Authentication required."),
138
    },
139
)
140
@api_view(["POST"])
1✔
141
def report_webcompat_issue(request):
1✔
142
    """Report a Relay issue from an extension or integration."""
143

144
    serializer = WebcompatIssueSerializer(data=request.data)
×
145
    if serializer.is_valid():
×
146
        info_logger.info("webcompat_issue", extra=serializer.data)
×
147
        incr_if_enabled("webcompat_issue", 1)
×
148
        for k, v in serializer.data.items():
×
149
            if v and k != "issue_on_domain":
×
150
                incr_if_enabled(f"webcompat_issue_{k}", 1)
×
151
        return Response(status=HTTP_201_CREATED)
×
152
    return Response(serializer.errors, status=HTTP_400_BAD_REQUEST)
×
153

154

155
def _get_example_plan(plan: Literal["premium", "phones", "bundle"]) -> dict[str, Any]:
1✔
156
    prices = {
1✔
157
        "premium": {"monthly": 1.99, "yearly": 0.99},
158
        "phones": {"monthly": 4.99, "yearly": 4.99},
159
        "bundle": {"monthly": 6.99, "yearly": 6.99},
160
    }
161
    monthly_price = {
1✔
162
        "id": f"price_{plan.title()}Monthlyxxxx",
163
        "currency": "usd",
164
        "price": prices[plan]["monthly"],
165
    }
166
    yearly_price = {
1✔
167
        "id": f"price_{plan.title()}Yearlyxxxx",
168
        "currency": "usd",
169
        "price": prices[plan]["yearly"],
170
    }
171
    return {
1✔
172
        "country_code": "US",
173
        "countries": ["CA", "US"],
174
        "available_in_country": True,
175
        "plan_country_lang_mapping": {
176
            "CA": {
177
                "*": {
178
                    "monthly": monthly_price,
179
                    "yearly": yearly_price,
180
                }
181
            },
182
            "US": {
183
                "*": {
184
                    "monthly": monthly_price,
185
                    "yearly": yearly_price,
186
                }
187
            },
188
        },
189
    }
190

191

192
@extend_schema(
1✔
193
    tags=["privaterelay"],
194
    responses={
195
        "200": OpenApiResponse(
196
            {"type": "object"},
197
            description="Site parameters",
198
            examples=[
199
                OpenApiExample(
200
                    "relay.firefox.com (partial)",
201
                    {
202
                        "FXA_ORIGIN": "https://accounts.firefox.com",
203
                        "PERIODICAL_PREMIUM_PRODUCT_ID": "prod_XXXXXXXXXXXXXX",
204
                        "GOOGLE_ANALYTICS_ID": "UA-########-##",
205
                        "GA4_MEASUREMENT_ID": "G-XXXXXXXXX",
206
                        "BUNDLE_PRODUCT_ID": "prod_XXXXXXXXXXXXXX",
207
                        "PHONE_PRODUCT_ID": "prod_XXXXXXXXXXXXXX",
208
                        "PERIODICAL_PREMIUM_PLANS": _get_example_plan("premium"),
209
                        "PHONE_PLANS": _get_example_plan("phones"),
210
                        "BUNDLE_PLANS": _get_example_plan("bundle"),
211
                        "BASKET_ORIGIN": "https://basket.mozilla.org",
212
                        "WAFFLE_FLAGS": [
213
                            ["foxfood", False],
214
                            ["phones", True],
215
                            ["bundle", True],
216
                        ],
217
                        "WAFFLE_SWITCHES": [],
218
                        "WAFFLE_SAMPLES": [],
219
                        "MAX_MINUTES_TO_VERIFY_REAL_PHONE": 5,
220
                    },
221
                )
222
            ],
223
        )
224
    },
225
)
226
@api_view()
1✔
227
@permission_classes([AllowAny])
1✔
228
def runtime_data(request):
1✔
229
    """Get data needed to present the Relay dashboard to a vistor or user."""
230
    flags = get_waffle_flag_model().get_all()
1✔
231
    flag_values = [(f.name, f.is_active(request)) for f in flags]
1✔
232
    switches = Switch.get_all()
1✔
233
    switch_values = [(s.name, s.is_active()) for s in switches]
1✔
234
    samples = Sample.get_all()
1✔
235
    sample_values = [(s.name, s.is_active()) for s in samples]
1✔
236
    return Response(
1✔
237
        {
238
            "FXA_ORIGIN": settings.FXA_BASE_ORIGIN,
239
            "PERIODICAL_PREMIUM_PRODUCT_ID": settings.PERIODICAL_PREMIUM_PROD_ID,
240
            "GOOGLE_ANALYTICS_ID": settings.GOOGLE_ANALYTICS_ID,
241
            "GA4_MEASUREMENT_ID": settings.GA4_MEASUREMENT_ID,
242
            "BUNDLE_PRODUCT_ID": settings.BUNDLE_PROD_ID,
243
            "PHONE_PRODUCT_ID": settings.PHONE_PROD_ID,
244
            "PERIODICAL_PREMIUM_PLANS": get_countries_info_from_request_and_mapping(
245
                request, get_premium_country_language_mapping()
246
            ),
247
            "PHONE_PLANS": get_countries_info_from_request_and_mapping(
248
                request, get_phone_country_language_mapping()
249
            ),
250
            "BUNDLE_PLANS": get_countries_info_from_request_and_mapping(
251
                request, get_bundle_country_language_mapping()
252
            ),
253
            "BASKET_ORIGIN": settings.BASKET_ORIGIN,
254
            "WAFFLE_FLAGS": flag_values,
255
            "WAFFLE_SWITCHES": switch_values,
256
            "WAFFLE_SAMPLES": sample_values,
257
            "MAX_MINUTES_TO_VERIFY_REAL_PHONE": (
258
                settings.MAX_MINUTES_TO_VERIFY_REAL_PHONE
259
            ),
260
        }
261
    )
262

263

264
@extend_schema(
1✔
265
    tags=["privaterelay"],
266
    parameters=[
267
        OpenApiParameter(
268
            name="Authorization",
269
            required=True,
270
            location="header",
271
            examples=[OpenApiExample("bearer", "Bearer XXXX-ZZZZ")],
272
            description="FXA Bearer Token. Can not be set in browsable API.",
273
        )
274
    ],
275
    request=OpenApiRequest(),
276
    responses={
277
        201: OpenApiResponse(description="Created; returned when user is created."),
278
        202: OpenApiResponse(
279
            description="Accepted; returned when user already exists."
280
        ),
281
        400: OpenApiResponse(
282
            description=(
283
                "Bad request; returned when request is missing Authorization: Bearer"
284
                " header or token value."
285
            )
286
        ),
287
        401: OpenApiResponse(
288
            description=(
289
                "Unauthorized; returned when the FXA token is invalid or expired."
290
            )
291
        ),
292
        404: OpenApiResponse(description="FXA did not return a user."),
293
        500: OpenApiResponse(description="No response from FXA server."),
294
    },
295
)
296
@api_view(["POST"])
1✔
297
@permission_classes([HasValidFxaToken, IsNewUser | IsActive])
1✔
298
@authentication_classes([FxaTokenAuthentication])
1✔
299
def terms_accepted_user(request: Request) -> Response:
1✔
300
    """
301
    Create a Relay user from an FXA token.
302

303
    See [API Auth doc][api-auth-doc] for details.
304

305
    [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
306
    """  # noqa: E501
307

308
    user = request.user
1✔
309
    introspect_response = request.auth
1✔
310
    if not isinstance(introspect_response, IntrospectionResponse):
1!
NEW
311
        raise ValueError(
×
312
            "Expected request.auth to be IntrospectionResponse,"
313
            f" got {type(introspect_response)}"
314
        )
315
    fxa_uid = introspect_response.fxa_id
1✔
316
    token = introspect_response.token
1✔
317

318
    existing_sa = False
1✔
319
    action: str | None = None
1✔
320
    profile_time_s: float | None = None
1✔
321
    if isinstance(user, User):
1✔
322
        socialaccount = SocialAccount.objects.get(user=user, provider="fxa")
1✔
323
        existing_sa = True
1✔
324
        action = "found_existing"
1✔
325

326
    if not existing_sa:
1✔
327
        # Get the user's profile
328
        fxa_prof_or_resp, profile_time_s = _get_fxa_profile_from_bearer_token(token)
1✔
329
        if isinstance(fxa_prof_or_resp, Response):
1✔
330
            return fxa_prof_or_resp
1✔
331
        fxa_profile = fxa_prof_or_resp
1✔
332

333
        # Since this takes time, see if another request created the SocialAccount
334
        try:
1✔
335
            socialaccount = SocialAccount.objects.get(uid=fxa_uid, provider="fxa")
1✔
336
            existing_sa = True
1✔
337
            action = "created_during_profile_fetch"
1✔
338
        except SocialAccount.DoesNotExist:
1✔
339
            pass
1✔
340

341
    if not existing_sa:
1✔
342
        # mypy type narrowing, but should always be true
343
        if not isinstance(fxa_profile, dict):  # pragma: no cover
344
            raise ValueError(f"fxa_profile is {type(fxa_profile)}, not dict")
345

346
        # Still no SocialAccount, attempt to create it
347
        socialaccount, response = _create_socialaccount_from_bearer_token(
1✔
348
            request, token, fxa_uid, fxa_profile
349
        )
350
        if response:
1✔
351
            return response
1✔
352
        action = "created"
1✔
353

354
    status_code = 202 if existing_sa else 201
1✔
355
    info_logger.info(
1✔
356
        "terms_accepted_user",
357
        extra={
358
            "social_account": socialaccount.uid,
359
            "status_code": status_code,
360
            "action": action,
361
            "introspection_from_cache": introspect_response.from_cache,
362
            "introspection_time_s": introspect_response.request_s,
363
            "profile_time_s": profile_time_s,
364
        },
365
    )
366
    return Response(status=status_code)
1✔
367

368

369
def _create_socialaccount_from_bearer_token(
1✔
370
    request: Request, token: str, fxa_uid: str, fxa_profile: dict[str, Any]
371
) -> tuple[SocialAccount, None] | tuple[None, Response]:
372
    """Create a new Relay user with a SocialAccount from an FXA Bearer Token."""
373

374
    # request is not exactly the request object that FirefoxAccountsProvider expects,
375
    # but it has all of the necessary attributes to initialize the Provider
376
    provider = get_social_adapter().get_provider(request, "fxa")
1✔
377

378
    # sociallogin_from_response does not save the new user that was created
379
    # https://github.com/pennersr/django-allauth/blob/65.3.0/allauth/socialaccount/providers/base/provider.py#L84
380
    social_login = provider.sociallogin_from_response(request, fxa_profile)
1✔
381

382
    # Complete social login is called by callback, see
383
    # https://github.com/pennersr/django-allauth/blob/65.3.0/allauth/socialaccount/providers/oauth/views.py#L116
384
    # for what we are mimicking to create new SocialAccount, User, and Profile for
385
    # the new Relay user from Firefox Since this is a Resource Provider/Server flow
386
    # and are NOT a Relying Party (RP) of FXA No social token information is stored
387
    # (no Social Token object created).
388
    try:
1✔
389
        complete_social_login(request, social_login)
1✔
390
    except (IntegrityError, NoReverseMatch) as e:
1✔
391
        # TODO: use this logging to fix the underlying issue
392
        # MPP-3473: NoReverseMatch socialaccount_signup
393
        #  Another user has the same email?
394
        log_extra = {"exception": str(e), "social_login_state": social_login.state}
1✔
395
        if fxa_profile.get("metricsEnabled", False):
1✔
396
            log_extra["fxa_uid"] = fxa_uid
1✔
397
        logger.error("socialaccount_signup_error", extra=log_extra)
1✔
398
        return None, Response(status=500)
1✔
399

400
    # complete_social_login writes ['account_verified_email', 'user_created',
401
    # '_auth_user_id', '_auth_user_backend', '_auth_user_hash'] on
402
    # request.session which sets the cookie because complete_social_login does
403
    # the "login" The user did not actually log in, logout to clear the session
404
    get_account_adapter(request).logout(request)
1✔
405

406
    sa: SocialAccount = SocialAccount.objects.get(uid=fxa_uid, provider="fxa")
1✔
407

408
    # Indicate profile was created from the resource flow
409
    profile = sa.user.profile
1✔
410
    profile.created_by = "firefox_resource"
1✔
411
    profile.save()
1✔
412
    return sa, None
1✔
413

414

415
def _get_fxa_profile_from_bearer_token(
1✔
416
    token: str,
417
) -> tuple[dict[str, Any] | Response, float]:
418
    """Use a bearer token to get the Mozilla Account user's profile data"""
419

420
    error: None | Literal["timeout", "bad_response"] = None
1✔
421

422
    try:
1✔
423
        with Timer(logger=None) as profile_timer:
1✔
424
            fxa_profile_resp = requests.get(
1✔
425
                FXA_PROFILE_URL,
426
                headers={"Authorization": f"Bearer {token}"},
427
                timeout=settings.FXA_REQUESTS_TIMEOUT_SECONDS,
428
            )
429
    except requests.Timeout:
1✔
430
        error = "timeout"
1✔
431
    profile_time_s = round(profile_timer.last, 3)
1✔
432

433
    if error is None and not (fxa_profile_resp.ok and fxa_profile_resp.content):
1✔
434
        error = "bad_response"
1✔
435

436
    histogram_if_enabled(
1✔
437
        name="accounts_profile_ms",
438
        value=int(profile_time_s * 1000),
439
        tags=[generate_tag("result", error or "OK")],
440
    )
441

442
    if error == "timeout":
1✔
443
        logger.error(
1✔
444
            "terms_accepted_user: timeout",
445
            extra={
446
                "profile_time_s": profile_time_s,
447
            },
448
        )
449
        return (
1✔
450
            Response(
451
                "Account profile request timeout, try again later.",
452
                status=503,
453
            ),
454
            profile_time_s,
455
        )
456
    elif error == "bad_response":
1✔
457
        logger.error(
1✔
458
            "terms_accepted_user: bad account profile response",
459
            extra={
460
                "status_code": fxa_profile_resp.status_code,
461
                "content": fxa_profile_resp.content,
462
                "profile_time_s": profile_time_s,
463
            },
464
        )
465
        return (
1✔
466
            Response(
467
                data={"detail": "Did not receive a 200 response for account profile."},
468
                status=500,
469
            ),
470
            profile_time_s,
471
        )
472
    else:
473
        return fxa_profile_resp.json(), profile_time_s
1✔
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