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

mozilla / fx-private-relay / d6bdb9d6-e1e5-45af-a53f-1ade25633cbd

14 Jan 2025 11:02PM CUT coverage: 85.391% (+0.3%) from 85.088%
d6bdb9d6-e1e5-45af-a53f-1ade25633cbd

Pull #5272

circleci

jwhitlock
Update docstring
Pull Request #5272: WIP MPP-3505: RewriteAccount Bearer Token Authentication

2499 of 3623 branches covered (68.98%)

Branch coverage included in aggregate %.

829 of 830 new or added lines in 6 files covered. (99.88%)

1 existing line in 1 file now uncovered.

17327 of 19595 relevant lines covered (88.43%)

9.78 hits per line

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

89.56
/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 rest_framework.decorators import (
1✔
25
    api_view,
26
    authentication_classes,
27
    permission_classes,
28
)
29
from rest_framework.permissions import AllowAny, IsAuthenticated
1✔
30
from rest_framework.request import Request
1✔
31
from rest_framework.response import Response
1✔
32
from rest_framework.status import HTTP_201_CREATED, HTTP_400_BAD_REQUEST
1✔
33
from rest_framework.viewsets import ModelViewSet
1✔
34
from waffle import get_waffle_flag_model
1✔
35
from waffle.models import Sample, Switch
1✔
36

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

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

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

61

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

73

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

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

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

87

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

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

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

101

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

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

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

115

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

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

153

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

190

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

262

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

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

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

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

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

325
    if not existing_sa:
1✔
326
        # Get the user's profile
327
        fxa_profile, response, profile_time_s = _get_fxa_profile_from_bearer_token(
1✔
328
            token
329
        )
330
        if response:
1✔
331
            return response
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], None, float] | tuple[None, Response, None]:
418
    """Use a bearer token to get the Mozilla Account user's profile data"""
419
    # Use the bearer token
420
    try:
1✔
421
        with Timer(logger=None) as profile_timer:
1✔
422
            fxa_profile_resp = requests.get(
1✔
423
                FXA_PROFILE_URL,
424
                headers={"Authorization": f"Bearer {token}"},
425
                timeout=settings.FXA_REQUESTS_TIMEOUT_SECONDS,
426
            )
427
    except requests.Timeout:
1✔
428
        logger.error(
1✔
429
            "terms_accepted_user: timeout",
430
            extra={
431
                "profile_time_s": round(profile_timer.last, 3),
432
            },
433
        )
434
        return (
1✔
435
            None,
436
            Response(
437
                "Account profile request timeout, try again later.",
438
                status=503,
439
            ),
440
            None,
441
        )
442

443
    profile_time_s = round(profile_timer.last, 3)
1✔
444
    if not (fxa_profile_resp.ok and fxa_profile_resp.content):
1✔
445
        logger.error(
1✔
446
            "terms_accepted_user: bad account profile response",
447
            extra={
448
                "status_code": fxa_profile_resp.status_code,
449
                "content": fxa_profile_resp.content,
450
                "profile_time_s": profile_time_s,
451
            },
452
        )
453
        return (
1✔
454
            None,
455
            Response(
456
                data={"detail": "Did not receive a 200 response for account profile."},
457
                status=500,
458
            ),
459
            None,
460
        )
461
    return fxa_profile_resp.json(), None, 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