зеркало из https://github.com/mozilla/kitsune.git
receive and process firefox accounts notifications (#4403)
handle delete-user, subscription-state-change and password-change SET (#4348) Co-authored-by: Tasos Katsoulas <akatsoulas@gmail.com>
This commit is contained in:
Родитель
d6059faba3
Коммит
ca14ac88a2
|
@ -137,6 +137,7 @@ app:
|
|||
fxa_store_access_token: True
|
||||
fxa_store_id_token: True
|
||||
fxa_support_form: "https://accounts.stage.mozaws.net/support"
|
||||
fxa_set_issuer: "https://accounts.stage.mozaws.net"
|
||||
oidc_op_authorization_endpoint: SECRET
|
||||
oidc_op_token_endpoint: SECRET
|
||||
oidc_op_user_endpoint: SECRET
|
||||
|
|
|
@ -546,6 +546,7 @@ MIDDLEWARE_CLASSES = (
|
|||
|
||||
'kitsune.sumo.middleware.InAAQMiddleware',
|
||||
'kitsune.users.middleware.LogoutDeactivatedUsersMiddleware',
|
||||
'kitsune.users.middleware.LogoutInvalidatedSessionsMiddleware',
|
||||
)
|
||||
|
||||
# SecurityMiddleware settings
|
||||
|
@ -602,6 +603,7 @@ else:
|
|||
'users.fxa_authentication_init',
|
||||
'users.fxa_authentication_callback',
|
||||
'users.fxa_logout_url',
|
||||
'users.fxa_webhook'
|
||||
]
|
||||
# Firefox Accounts configuration
|
||||
FXA_OP_TOKEN_ENDPOINT = config('FXA_OP_TOKEN_ENDPOINT', default='')
|
||||
|
@ -620,6 +622,7 @@ else:
|
|||
FXA_STORE_ACCESS_TOKEN = config('FXA_STORE_ACCESS_TOKEN', default=False, cast=bool)
|
||||
FXA_STORE_ID_TOKEN = config('FXA_STORE_ID_TOKEN', default=False, cast=bool)
|
||||
FXA_SUPPORT_FORM = config('FXA_SUPPORT_FORM', default='https://accounts.firefox.com/support')
|
||||
FXA_SET_ISSUER = config('FXA_SET_ISSUER', default='')
|
||||
|
||||
ADMIN_REDIRECT_URL = config('ADMIN_REDIRECT_URL', default=None)
|
||||
|
||||
|
|
|
@ -17,6 +17,8 @@ from django.utils import translation
|
|||
from django.utils.cache import (add_never_cache_headers,
|
||||
patch_response_headers, patch_vary_headers)
|
||||
from django.utils.encoding import iri_to_uri, smart_str, smart_unicode
|
||||
from django.urls import resolve, Resolver404
|
||||
|
||||
from enforce_host import EnforceHostMiddleware
|
||||
from mozilla_django_oidc.middleware import SessionRefresh
|
||||
|
||||
|
@ -78,6 +80,15 @@ class LocaleURLMiddleware(object):
|
|||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
try:
|
||||
urlname = resolve(request.path_info).url_name
|
||||
except Resolver404:
|
||||
urlname = None
|
||||
|
||||
if urlname in settings.OIDC_EXEMPT_URLS:
|
||||
translation.activate(settings.LANGUAGE_CODE)
|
||||
return
|
||||
|
||||
prefixer = Prefixer(request)
|
||||
set_url_prefixer(prefixer)
|
||||
full_path = prefixer.fix(prefixer.shortened_path)
|
||||
|
|
|
@ -2,7 +2,7 @@ from django import forms
|
|||
from django.contrib import admin
|
||||
|
||||
from kitsune.users import monkeypatch
|
||||
from kitsune.users.models import Profile
|
||||
from kitsune.users.models import AccountEvent, Profile
|
||||
|
||||
|
||||
class ProfileAdminForm(forms.ModelForm):
|
||||
|
@ -60,5 +60,15 @@ class ProfileAdmin(admin.ModelAdmin):
|
|||
obj.save()
|
||||
|
||||
|
||||
class AccountEventAdmin(admin.ModelAdmin):
|
||||
"""Admin entry for SET tokens."""
|
||||
list_display = ['status', 'event_type', 'fxa_uid']
|
||||
search_fields = ['fxa_uid', 'profile__user__username', 'profile__user__email', 'profile__name']
|
||||
|
||||
class Meta:
|
||||
model = AccountEvent
|
||||
|
||||
|
||||
admin.site.register(Profile, ProfileAdmin)
|
||||
admin.site.register(AccountEvent, AccountEventAdmin)
|
||||
monkeypatch.patch_all()
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from django.contrib.auth import logout
|
||||
from django.http import HttpResponseRedirect
|
||||
from datetime import datetime
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
from kitsune.sumo.urlresolvers import reverse
|
||||
|
||||
|
@ -17,3 +19,23 @@ class LogoutDeactivatedUsersMiddleware(object):
|
|||
|
||||
logout(request)
|
||||
return HttpResponseRedirect(reverse('home'))
|
||||
|
||||
|
||||
class LogoutInvalidatedSessionsMiddleware(MiddlewareMixin):
|
||||
"""Logs out any sessions started before a user changed their
|
||||
Firefox Accounts password.
|
||||
"""
|
||||
def process_request(self, request):
|
||||
|
||||
user = request.user
|
||||
|
||||
# TODO: py3 upgrade: change to is_authenticated attribute
|
||||
if user.is_authenticated():
|
||||
first_seen = request.session.get("first_seen")
|
||||
if first_seen:
|
||||
change_time = user.profile.fxa_password_change
|
||||
if change_time and change_time > first_seen:
|
||||
logout(request)
|
||||
return HttpResponseRedirect(reverse('home'))
|
||||
else:
|
||||
request.session["first_seen"] = datetime.utcnow()
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.29 on 2020-04-02 09:29
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [(b'users', '0020_accountevent'), (b'users', '0021_auto_20200402_0424'), (b'users', '0022_auto_20200402_0429')]
|
||||
|
||||
dependencies = [
|
||||
('users', '0019_auto_20190917_0422'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AccountEvent',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.PositiveSmallIntegerField(blank=True, choices=[(1, b'unprocessed'), (2, b'processed'), (3, b'errored')], default=1)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_modified', models.DateTimeField(auto_now=True)),
|
||||
('events', models.TextField(max_length=4096)),
|
||||
('event_type', models.PositiveSmallIntegerField(choices=[(1, b'password-change'), (2, b'profile-change'), (3, b'subscription-state-change'), (4, b'delete-user')], default=None, null=True)),
|
||||
('fxa_uid', models.CharField(blank=True, max_length=128, null=True, unique=True)),
|
||||
('jwt_id', models.CharField(max_length=256)),
|
||||
('issued_at', models.CharField(max_length=32)),
|
||||
('profile', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='account_events', to='users.Profile')),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,44 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.29 on 2020-05-22 07:16
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0020_accountevent_squashed_0022_auto_20200402_0429'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='accountevent',
|
||||
options={'ordering': ['-last_modified']},
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='accountevent',
|
||||
old_name='events',
|
||||
new_name='body',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='fxa_password_change',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountevent',
|
||||
name='event_type',
|
||||
field=models.CharField(blank=True, default=b'', max_length=256),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountevent',
|
||||
name='fxa_uid',
|
||||
field=models.CharField(blank=True, default=b'', max_length=128),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountevent',
|
||||
name='status',
|
||||
field=models.PositiveSmallIntegerField(blank=True, choices=[(1, b'unprocessed'), (2, b'processed'), (3, b'ignored'), (4, b'not-implemented')], default=1),
|
||||
),
|
||||
]
|
|
@ -8,8 +8,6 @@ from django.contrib.auth.models import User
|
|||
from django.db import models
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _lazy
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from kitsune.lib.countries import COUNTRIES
|
||||
from kitsune.products.models import Product
|
||||
from kitsune.search.es_utils import UnindexMeBro
|
||||
|
@ -20,12 +18,14 @@ from kitsune.sumo.models import LocaleField, ModelBase
|
|||
from kitsune.sumo.urlresolvers import reverse
|
||||
from kitsune.sumo.utils import auto_delete_files
|
||||
from kitsune.users.validators import TwitterValidator
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
log = logging.getLogger('k.users')
|
||||
|
||||
|
||||
SHA1_RE = re.compile('^[a-f0-9]{40}$')
|
||||
CONTRIBUTOR_GROUP = 'Registered as contributor'
|
||||
SET_ID_PREFIX = 'https://schemas.accounts.firefox.com/event/'
|
||||
|
||||
|
||||
@auto_delete_files
|
||||
|
@ -81,6 +81,7 @@ class Profile(ModelBase, SearchMixin):
|
|||
fxa_uid = models.CharField(blank=True, null=True, unique=True, max_length=128)
|
||||
fxa_avatar = models.URLField(max_length=512, blank=True, default='')
|
||||
products = models.ManyToManyField(Product, related_name='subscribed_users')
|
||||
fxa_password_change = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
class Meta(object):
|
||||
permissions = (('view_karma_points', 'Can view karma points'),
|
||||
|
@ -419,3 +420,42 @@ class Deactivation(models.Model):
|
|||
def __unicode__(self):
|
||||
return u'%s was deactivated by %s on %s' % (self.user, self.moderator,
|
||||
self.date)
|
||||
|
||||
|
||||
class AccountEvent(models.Model):
|
||||
"""Stores the events received from Firefox Accounts.
|
||||
|
||||
These events are processed by celery and the correct status is assigned in each entry.
|
||||
"""
|
||||
|
||||
# Status of an event entry.
|
||||
UNPROCESSED = 1
|
||||
PROCESSED = 2
|
||||
IGNORED = 3
|
||||
NOT_IMPLEMENTED = 4
|
||||
EVENT_STATUS = (
|
||||
(UNPROCESSED, 'unprocessed'),
|
||||
(PROCESSED, 'processed'),
|
||||
(IGNORED, 'ignored'),
|
||||
(NOT_IMPLEMENTED, 'not-implemented'),
|
||||
)
|
||||
|
||||
PASSWORD_CHANGE = 'password-change'
|
||||
PROFILE_CHANGE = 'profile-change'
|
||||
SUBSCRIPTION_STATE_CHANGE = 'subscription-state-change'
|
||||
DELETE_USER = 'delete-user'
|
||||
|
||||
status = models.PositiveSmallIntegerField(choices=EVENT_STATUS,
|
||||
default=UNPROCESSED,
|
||||
blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_modified = models.DateTimeField(auto_now=True)
|
||||
body = models.TextField(max_length=4096, blank=False)
|
||||
event_type = models.CharField(max_length=256, default="", blank=True)
|
||||
fxa_uid = models.CharField(max_length=128, default="", blank=True)
|
||||
jwt_id = models.CharField(max_length=256)
|
||||
issued_at = models.CharField(max_length=32)
|
||||
profile = models.ForeignKey(Profile, related_name='account_events', null=True)
|
||||
|
||||
class Meta(object):
|
||||
ordering = ["-last_modified"]
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
from celery import task
|
||||
from kitsune.sumo.decorators import timeit
|
||||
from kitsune.users.models import AccountEvent
|
||||
from kitsune.users.utils import anonymize_user
|
||||
from kitsune.products.models import Product
|
||||
|
||||
|
||||
@task
|
||||
@timeit
|
||||
def process_event_delete_user(event_id):
|
||||
event = AccountEvent.objects.get(id=event_id)
|
||||
|
||||
anonymize_user(event.profile.user)
|
||||
|
||||
event.status = AccountEvent.PROCESSED
|
||||
event.save()
|
||||
|
||||
|
||||
@task
|
||||
@timeit
|
||||
def process_event_subscription_state_change(event_id):
|
||||
event = AccountEvent.objects.get(id=event_id)
|
||||
body = json.loads(event.body)
|
||||
|
||||
last_event = AccountEvent.objects.filter(
|
||||
profile_id=event.profile.pk,
|
||||
status=AccountEvent.PROCESSED,
|
||||
event_type=AccountEvent.SUBSCRIPTION_STATE_CHANGE
|
||||
).first()
|
||||
if last_event:
|
||||
last_event_body = json.loads(last_event.body)
|
||||
if last_event_body["changeTime"] > body["changeTime"]:
|
||||
event.status = AccountEvent.IGNORED
|
||||
event.save()
|
||||
return
|
||||
|
||||
products = Product.objects.filter(codename__in=body["capabilities"])
|
||||
if body["isActive"]:
|
||||
event.profile.products.add(*products)
|
||||
else:
|
||||
event.profile.products.remove(*products)
|
||||
event.status = AccountEvent.PROCESSED
|
||||
event.save()
|
||||
|
||||
|
||||
@task
|
||||
@timeit
|
||||
def process_event_password_change(event_id):
|
||||
event = AccountEvent.objects.get(id=event_id)
|
||||
body = json.loads(event.body)
|
||||
|
||||
change_time = datetime.utcfromtimestamp(body["changeTime"] / 1000.0)
|
||||
|
||||
if (event.profile.fxa_password_change and event.profile.fxa_password_change > change_time):
|
||||
event.status = AccountEvent.IGNORED
|
||||
event.save()
|
||||
return
|
||||
|
||||
event.profile.fxa_password_change = change_time
|
||||
event.profile.save()
|
||||
event.status = AccountEvent.PROCESSED
|
||||
event.save()
|
|
@ -1,10 +1,9 @@
|
|||
import factory
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from tidings.models import Watch
|
||||
|
||||
from kitsune.sumo.tests import FuzzyUnicode, LocalizingClient, TestCase
|
||||
from kitsune.users.models import Profile, Setting
|
||||
from kitsune.users.models import AccountEvent, Profile, Setting
|
||||
from tidings.models import Watch
|
||||
|
||||
|
||||
class TestCaseBase(TestCase):
|
||||
|
@ -87,3 +86,14 @@ def tidings_watch(save=False, **kwargs):
|
|||
if save:
|
||||
w.save()
|
||||
return w
|
||||
|
||||
|
||||
class AccountEventFactory(factory.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = AccountEvent
|
||||
|
||||
status = AccountEvent.UNPROCESSED
|
||||
fxa_uid = "54321"
|
||||
jwt_id = "e19ed6c5-4816-4171-aa43-56ffe80dbda1"
|
||||
issued_at = "1565720808"
|
||||
profile = factory.SubFactory(ProfileFactory)
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
from datetime import datetime, timedelta
|
||||
|
||||
from django.test.client import RequestFactory
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.contrib.auth.middleware import AuthenticationMiddleware
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
from kitsune.users.middleware import LogoutInvalidatedSessionsMiddleware
|
||||
from kitsune.sumo.tests import TestCase
|
||||
from kitsune.users.tests import UserFactory, ProfileFactory
|
||||
|
||||
|
||||
class LogoutInvalidatedSessionsMiddlewareTests(TestCase):
|
||||
def setUp(self):
|
||||
self.request = RequestFactory().request()
|
||||
session_middleware = SessionMiddleware()
|
||||
session_middleware.process_request(self.request)
|
||||
auth_middleware = AuthenticationMiddleware()
|
||||
auth_middleware.process_request(self.request)
|
||||
|
||||
def _process_request(self, request):
|
||||
middleware = LogoutInvalidatedSessionsMiddleware()
|
||||
return middleware.process_request(request)
|
||||
|
||||
def test_does_nothing_to_anonymous_sessions(self):
|
||||
session = self.request.session.items()
|
||||
|
||||
self._process_request(self.request)
|
||||
|
||||
self.assertEqual(session, self.request.session.items())
|
||||
|
||||
def test_adds_first_seen_to_users_sessions_once(self):
|
||||
self.request.user = UserFactory()
|
||||
|
||||
self._process_request(self.request)
|
||||
first_seen = self.request.session["first_seen"]
|
||||
|
||||
assert datetime.utcnow() > first_seen
|
||||
|
||||
self._process_request(self.request)
|
||||
|
||||
self.assertEqual(first_seen, self.request.session["first_seen"])
|
||||
|
||||
def test_does_nothing_if_no_fxa_password_change(self):
|
||||
user = UserFactory()
|
||||
self.request.user = user
|
||||
self.request.session["first_seen"] = datetime.utcfromtimestamp(1)
|
||||
|
||||
self._process_request(self.request)
|
||||
|
||||
self.assertEqual(user, self.request.user)
|
||||
|
||||
def test_logs_out_user_first_seen_before_password_change(self):
|
||||
self.request.user = ProfileFactory(
|
||||
fxa_password_change=datetime.utcnow() + timedelta(minutes=5)
|
||||
).user
|
||||
|
||||
self._process_request(self.request)
|
||||
response = self._process_request(self.request)
|
||||
|
||||
assert isinstance(self.request.user, AnonymousUser)
|
||||
assert isinstance(response, HttpResponseRedirect)
|
||||
|
||||
def test_does_nothing_if_user_first_seen_after_password_change(self):
|
||||
self.request.user = ProfileFactory(
|
||||
fxa_password_change=datetime.utcnow() - timedelta(minutes=5)
|
||||
).user
|
||||
user = self.request.user
|
||||
|
||||
self._process_request(self.request)
|
||||
self._process_request(self.request)
|
||||
|
||||
self.assertEqual(user, self.request.user)
|
|
@ -0,0 +1,157 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
from kitsune.products.tests import ProductFactory
|
||||
from kitsune.sumo.tests import TestCase
|
||||
from kitsune.users.tasks import (
|
||||
process_event_delete_user,
|
||||
process_event_subscription_state_change,
|
||||
process_event_password_change
|
||||
)
|
||||
from kitsune.users.tests import AccountEventFactory, ProfileFactory
|
||||
from kitsune.users.models import AccountEvent
|
||||
from nose.tools import eq_
|
||||
|
||||
|
||||
class AccountEventsTasksTestCase(TestCase):
|
||||
|
||||
def test_process_delete_user(self):
|
||||
profile = ProfileFactory()
|
||||
account_event = AccountEventFactory(
|
||||
body=json.dumps({}),
|
||||
event_type=AccountEvent.DELETE_USER,
|
||||
status=AccountEvent.UNPROCESSED,
|
||||
profile=profile
|
||||
)
|
||||
|
||||
assert profile.user.is_active
|
||||
|
||||
process_event_delete_user(account_event.id)
|
||||
|
||||
profile.user.refresh_from_db()
|
||||
account_event.refresh_from_db()
|
||||
|
||||
assert not profile.user.is_active
|
||||
eq_(account_event.status, AccountEvent.PROCESSED)
|
||||
|
||||
def test_process_subscription_state_change(self):
|
||||
product_1 = ProductFactory(codename="capability_1")
|
||||
product_2 = ProductFactory(codename="capability_2")
|
||||
product_3 = ProductFactory(codename="capability_3")
|
||||
profile = ProfileFactory()
|
||||
profile.products.add(product_3)
|
||||
account_event_1 = AccountEventFactory(
|
||||
body=json.dumps({
|
||||
"capabilities": ["capability_1", "capability_2"],
|
||||
"isActive": True,
|
||||
"changeTime": 1
|
||||
}),
|
||||
event_type=AccountEvent.SUBSCRIPTION_STATE_CHANGE,
|
||||
status=AccountEvent.UNPROCESSED,
|
||||
profile=profile
|
||||
)
|
||||
|
||||
process_event_subscription_state_change(account_event_1.id)
|
||||
account_event_1.refresh_from_db()
|
||||
|
||||
self.assertItemsEqual(profile.products.all(), [product_1, product_2, product_3])
|
||||
eq_(account_event_1.status, AccountEvent.PROCESSED)
|
||||
|
||||
account_event_2 = AccountEventFactory(
|
||||
body=json.dumps({
|
||||
"capabilities": ["capability_1", "capability_2"],
|
||||
"isActive": False,
|
||||
"changeTime": 2
|
||||
}),
|
||||
event_type=AccountEvent.SUBSCRIPTION_STATE_CHANGE,
|
||||
status=AccountEvent.UNPROCESSED,
|
||||
profile=profile
|
||||
)
|
||||
|
||||
process_event_subscription_state_change(account_event_2.id)
|
||||
account_event_2.refresh_from_db()
|
||||
|
||||
self.assertItemsEqual(profile.products.all(), [product_3])
|
||||
eq_(account_event_2.status, AccountEvent.PROCESSED)
|
||||
|
||||
def test_process_subscription_state_change_out_of_order(self):
|
||||
profile = ProfileFactory()
|
||||
account_event_1 = AccountEventFactory(
|
||||
body=json.dumps({
|
||||
"capabilities": ["capability_1"],
|
||||
"isActive": True,
|
||||
"changeTime": 1
|
||||
}),
|
||||
event_type=AccountEvent.SUBSCRIPTION_STATE_CHANGE,
|
||||
status=AccountEvent.UNPROCESSED,
|
||||
profile=profile
|
||||
)
|
||||
|
||||
process_event_subscription_state_change(account_event_1.id)
|
||||
account_event_1.refresh_from_db()
|
||||
eq_(account_event_1.status, AccountEvent.PROCESSED)
|
||||
|
||||
account_event_2 = AccountEventFactory(
|
||||
body=json.dumps({
|
||||
"capabilities": ["capability_1"],
|
||||
"isActive": True,
|
||||
"changeTime": 3
|
||||
}),
|
||||
event_type=AccountEvent.SUBSCRIPTION_STATE_CHANGE,
|
||||
status=AccountEvent.UNPROCESSED,
|
||||
profile=profile
|
||||
)
|
||||
|
||||
process_event_subscription_state_change(account_event_2.id)
|
||||
account_event_2.refresh_from_db()
|
||||
eq_(account_event_2.status, AccountEvent.PROCESSED)
|
||||
|
||||
account_event_3 = AccountEventFactory(
|
||||
body=json.dumps({
|
||||
"capabilities": ["capability_1"],
|
||||
"isActive": False,
|
||||
"changeTime": 2
|
||||
}),
|
||||
event_type=AccountEvent.SUBSCRIPTION_STATE_CHANGE,
|
||||
status=AccountEvent.UNPROCESSED,
|
||||
profile=profile
|
||||
)
|
||||
|
||||
process_event_subscription_state_change(account_event_3.id)
|
||||
account_event_3.refresh_from_db()
|
||||
eq_(account_event_3.status, AccountEvent.IGNORED)
|
||||
|
||||
def test_process_password_change(self):
|
||||
profile = ProfileFactory()
|
||||
account_event_1 = AccountEventFactory(
|
||||
body=json.dumps({
|
||||
"changeTime": 2000
|
||||
}),
|
||||
event_type=AccountEvent.PASSWORD_CHANGE,
|
||||
status=AccountEvent.UNPROCESSED,
|
||||
profile=profile
|
||||
)
|
||||
|
||||
process_event_password_change(account_event_1.id)
|
||||
|
||||
profile.refresh_from_db()
|
||||
account_event_1.refresh_from_db()
|
||||
|
||||
eq_(profile.fxa_password_change, datetime.utcfromtimestamp(2))
|
||||
eq_(account_event_1.status, AccountEvent.PROCESSED)
|
||||
|
||||
account_event_2 = AccountEventFactory(
|
||||
body=json.dumps({
|
||||
"changeTime": 1000
|
||||
}),
|
||||
event_type=AccountEvent.PASSWORD_CHANGE,
|
||||
status=AccountEvent.UNPROCESSED,
|
||||
profile=profile
|
||||
)
|
||||
|
||||
process_event_password_change(account_event_2.id)
|
||||
|
||||
profile.refresh_from_db()
|
||||
account_event_2.refresh_from_db()
|
||||
|
||||
eq_(profile.fxa_password_change, datetime.utcfromtimestamp(2))
|
||||
eq_(account_event_2.status, AccountEvent.IGNORED)
|
|
@ -1,19 +1,26 @@
|
|||
import json
|
||||
from textwrap import dedent
|
||||
|
||||
import mock
|
||||
from django.contrib import messages
|
||||
from django.contrib.messages.middleware import MessageMiddleware
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.test.client import RequestFactory
|
||||
from nose.tools import eq_
|
||||
from pyquery import PyQuery as pq
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from josepy import jwa, jwk, jws
|
||||
from kitsune.questions.models import Answer, Question
|
||||
from kitsune.questions.tests import AnswerFactory, QuestionFactory
|
||||
from kitsune.sumo.tests import LocalizingClient, TestCase
|
||||
from kitsune.sumo.urlresolvers import reverse
|
||||
from kitsune.users.models import (CONTRIBUTOR_GROUP, Deactivation, Profile,
|
||||
Setting)
|
||||
from kitsune.users.tests import GroupFactory, UserFactory, add_permission
|
||||
from kitsune.users.models import (CONTRIBUTOR_GROUP, AccountEvent,
|
||||
Deactivation, Profile, Setting)
|
||||
from kitsune.users.tests import (GroupFactory,
|
||||
UserFactory, add_permission)
|
||||
from kitsune.users.views import edit_profile
|
||||
from nose.tools import eq_
|
||||
from pyquery import PyQuery as pq
|
||||
from kitsune.users.tests import ProfileFactory
|
||||
from functools import wraps
|
||||
|
||||
|
||||
class MakeContributorTests(TestCase):
|
||||
|
@ -170,3 +177,275 @@ class FXAAuthenticationTests(TestCase):
|
|||
url = reverse('users.fxa_authentication_init') + '?is_contributor=True'
|
||||
self.client.get(url)
|
||||
assert self.client.session.get('is_contributor')
|
||||
|
||||
|
||||
def setup_key(test):
|
||||
@wraps(test)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
with mock.patch('kitsune.users.views.requests') as mock_requests:
|
||||
pem = dedent("""
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIBOgIBAAJBAKx1c7RR7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79
|
||||
vukFhN9HFoHZiUvOjm0c+pVE6K+EdE/twuUCAwEAAQJAMbrEnJCrQe8YqAbw1/Bn
|
||||
elAzIamndfE3U8bTavf9sgFpS4HL83rhd6PDbvx81ucaJAT/5x048fM/nFl4fzAc
|
||||
mQIhAOF/a9o3EIsDKEmUl+Z1OaOiUxDF3kqWSmALEsmvDhwXAiEAw8ljV5RO/rUp
|
||||
Zu2YMDFq3MKpyyMgBIJ8CxmGRc6gCmMCIGRQzkcmhfqBrhOFwkmozrqIBRIKJIjj
|
||||
8TRm2LXWZZ2DAiAqVO7PztdNpynugUy4jtbGKKjBrTSNBRGA7OHlUgm0dQIhALQq
|
||||
6oGU29Vxlvt3k0vmiRKU4AVfLyNXIGtcWcNG46h/
|
||||
-----END RSA PRIVATE KEY-----
|
||||
""")
|
||||
key = jwk.JWKRSA.load(pem)
|
||||
pubkey = {
|
||||
"kty": "RSA",
|
||||
"alg": "RS256",
|
||||
"kid": "123"
|
||||
}
|
||||
pubkey.update(key.public_key().fields_to_partial_json())
|
||||
|
||||
mock_json = mock.Mock()
|
||||
mock_json.json.return_value = {
|
||||
"keys": [
|
||||
pubkey
|
||||
]
|
||||
}
|
||||
mock_requests.get.return_value = mock_json
|
||||
test(self, key, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
@override_settings(FXA_RP_CLIENT_ID="12345")
|
||||
@override_settings(FXA_SET_ISSUER="http://example.com")
|
||||
class WebhookViewTests(TestCase):
|
||||
|
||||
def _call_webhook(self, events, key=None):
|
||||
payload = json.dumps({
|
||||
"iss": "http://example.com",
|
||||
"sub": "54321",
|
||||
"aud": "12345",
|
||||
"iat": 1565720808,
|
||||
"jti": "e19ed6c5-4816-4171-aa43-56ffe80dbda1",
|
||||
"events": events
|
||||
})
|
||||
jwt = jws.JWS.sign(
|
||||
payload=payload,
|
||||
key=key,
|
||||
alg=jwa.RS256,
|
||||
kid='123',
|
||||
protect=frozenset(['alg', 'kid'])
|
||||
).to_compact()
|
||||
return self.client.post(
|
||||
reverse('users.fxa_webhook'),
|
||||
content_type="",
|
||||
HTTP_AUTHORIZATION="Bearer " + jwt
|
||||
)
|
||||
|
||||
@mock.patch('kitsune.users.views.process_event_password_change')
|
||||
@setup_key
|
||||
def test_adds_event_to_db(self, key, process_mock):
|
||||
profile = ProfileFactory(fxa_uid="54321")
|
||||
events = {
|
||||
"https://schemas.accounts.firefox.com/event/password-change": {
|
||||
"changeTime": 1565721242227
|
||||
}
|
||||
}
|
||||
|
||||
eq_(0, AccountEvent.objects.count())
|
||||
|
||||
response = self._call_webhook(events, key)
|
||||
|
||||
eq_(202, response.status_code)
|
||||
eq_(1, AccountEvent.objects.count())
|
||||
eq_(1, process_mock.delay.call_count)
|
||||
|
||||
account_event = AccountEvent.objects.last()
|
||||
eq_(account_event.status, AccountEvent.UNPROCESSED)
|
||||
self.assertEqual(json.loads(account_event.body), events.values()[0])
|
||||
eq_(account_event.event_type, AccountEvent.PASSWORD_CHANGE)
|
||||
eq_(account_event.fxa_uid, "54321")
|
||||
eq_(account_event.jwt_id, "e19ed6c5-4816-4171-aa43-56ffe80dbda1")
|
||||
eq_(account_event.issued_at, "1565720808")
|
||||
eq_(account_event.profile, profile)
|
||||
|
||||
@mock.patch('kitsune.users.views.process_event_subscription_state_change')
|
||||
@setup_key
|
||||
def test_adds_multiple_events_to_db(self, key, process_mock):
|
||||
profile = ProfileFactory(fxa_uid="54321")
|
||||
events = {
|
||||
"https://schemas.accounts.firefox.com/event/profile-change": {},
|
||||
"https://schemas.accounts.firefox.com/event/subscription-state-change": {
|
||||
"capabilities": ["capability_1", "capability_2"],
|
||||
"isActive": True,
|
||||
"changeTime": 1565721242227
|
||||
}
|
||||
}
|
||||
|
||||
eq_(0, AccountEvent.objects.count())
|
||||
|
||||
response = self._call_webhook(events, key)
|
||||
|
||||
eq_(202, response.status_code)
|
||||
eq_(2, AccountEvent.objects.count())
|
||||
eq_(1, process_mock.delay.call_count)
|
||||
|
||||
account_event_1 = AccountEvent.objects.get(
|
||||
event_type=AccountEvent.PROFILE_CHANGE
|
||||
)
|
||||
account_event_2 = AccountEvent.objects.get(
|
||||
event_type=AccountEvent.SUBSCRIPTION_STATE_CHANGE
|
||||
)
|
||||
|
||||
self.assertEqual(json.loads(account_event_1.body), {})
|
||||
self.assertEqual(json.loads(account_event_2.body), events.values()[1])
|
||||
|
||||
eq_(account_event_1.status, AccountEvent.NOT_IMPLEMENTED)
|
||||
eq_(account_event_2.status, AccountEvent.UNPROCESSED)
|
||||
eq_(account_event_1.fxa_uid, "54321")
|
||||
eq_(account_event_2.fxa_uid, "54321")
|
||||
eq_(account_event_1.jwt_id, "e19ed6c5-4816-4171-aa43-56ffe80dbda1")
|
||||
eq_(account_event_2.jwt_id, "e19ed6c5-4816-4171-aa43-56ffe80dbda1")
|
||||
eq_(account_event_1.issued_at, "1565720808")
|
||||
eq_(account_event_2.issued_at, "1565720808")
|
||||
eq_(account_event_1.profile, profile)
|
||||
eq_(account_event_2.profile, profile)
|
||||
|
||||
@mock.patch('kitsune.users.views.process_event_delete_user')
|
||||
@setup_key
|
||||
def test_handles_unknown_events(self, key, process_mock):
|
||||
profile = ProfileFactory(fxa_uid="54321")
|
||||
events = {
|
||||
"https://schemas.accounts.firefox.com/event/delete-user": {},
|
||||
"https://schemas.accounts.firefox.com/event/foobar": {},
|
||||
"barfoo": {}
|
||||
}
|
||||
|
||||
eq_(0, AccountEvent.objects.count())
|
||||
|
||||
response = self._call_webhook(events, key)
|
||||
|
||||
eq_(202, response.status_code)
|
||||
eq_(3, AccountEvent.objects.count())
|
||||
eq_(1, process_mock.delay.call_count)
|
||||
|
||||
account_event_1 = AccountEvent.objects.get(
|
||||
event_type=AccountEvent.DELETE_USER
|
||||
)
|
||||
account_event_2 = AccountEvent.objects.get(
|
||||
event_type="foobar"
|
||||
)
|
||||
account_event_3 = AccountEvent.objects.get(
|
||||
event_type="barfoo"
|
||||
)
|
||||
|
||||
self.assertEqual(json.loads(account_event_1.body), {})
|
||||
self.assertEqual(json.loads(account_event_2.body), {})
|
||||
self.assertEqual(json.loads(account_event_3.body), {})
|
||||
eq_(account_event_1.status, AccountEvent.UNPROCESSED)
|
||||
eq_(account_event_2.status, AccountEvent.NOT_IMPLEMENTED)
|
||||
eq_(account_event_3.status, AccountEvent.NOT_IMPLEMENTED)
|
||||
eq_(account_event_1.fxa_uid, "54321")
|
||||
eq_(account_event_2.fxa_uid, "54321")
|
||||
eq_(account_event_3.fxa_uid, "54321")
|
||||
eq_(account_event_1.jwt_id, "e19ed6c5-4816-4171-aa43-56ffe80dbda1")
|
||||
eq_(account_event_2.jwt_id, "e19ed6c5-4816-4171-aa43-56ffe80dbda1")
|
||||
eq_(account_event_3.jwt_id, "e19ed6c5-4816-4171-aa43-56ffe80dbda1")
|
||||
eq_(account_event_1.issued_at, "1565720808")
|
||||
eq_(account_event_2.issued_at, "1565720808")
|
||||
eq_(account_event_3.issued_at, "1565720808")
|
||||
eq_(account_event_1.profile, profile)
|
||||
eq_(account_event_2.profile, profile)
|
||||
eq_(account_event_3.profile, profile)
|
||||
|
||||
@mock.patch('kitsune.users.views.process_event_delete_user')
|
||||
@setup_key
|
||||
def test_handles_no_user(self, key, process_mock):
|
||||
events = {"https://schemas.accounts.firefox.com/event/delete-user": {}}
|
||||
|
||||
eq_(0, AccountEvent.objects.count())
|
||||
|
||||
response = self._call_webhook(events, key)
|
||||
|
||||
eq_(202, response.status_code)
|
||||
eq_(1, AccountEvent.objects.count())
|
||||
eq_(0, process_mock.delay.call_count)
|
||||
|
||||
account_event = AccountEvent.objects.last()
|
||||
eq_(account_event.status, AccountEvent.IGNORED)
|
||||
self.assertEqual(json.loads(account_event.body), {})
|
||||
eq_(account_event.event_type, AccountEvent.DELETE_USER)
|
||||
eq_(account_event.fxa_uid, "54321")
|
||||
eq_(account_event.jwt_id, "e19ed6c5-4816-4171-aa43-56ffe80dbda1")
|
||||
eq_(account_event.issued_at, "1565720808")
|
||||
eq_(account_event.profile, None)
|
||||
|
||||
@setup_key
|
||||
def test_invalid_private_key(self, key):
|
||||
payload = json.dumps({
|
||||
"iss": "http://example.com",
|
||||
"sub": "54321",
|
||||
"aud": "12345",
|
||||
"iat": 1565720808,
|
||||
"jti": "e19ed6c5-4816-4171-aa43-56ffe80dbda1",
|
||||
"events": {
|
||||
"https://schemas.accounts.firefox.com/event/password-change": {
|
||||
"changeTime": 1565721242227
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
pem = dedent("""
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIBOwIBAAJBAK+qEuIq8XPibyHVbTqk/mPinYKq1CwkPGIs+KjYSUCJdgBIATna
|
||||
uETAv/tmlSuyWi+S2RZGqHfrPSnclZ/zFlECAwEAAQJAKmlmm8KAf1kpOcL8107k
|
||||
uJsLKnQyO+IXziBLfQCTVwgyoggtyhFgrm+r81/j8bHYAGuPLkOxSoTLgw36ziZH
|
||||
wQIhAOHRxpQmT/CQPKt7kvoFa9IOo+mu0CmRsfpQThz3kq2pAiEAxyRIk3cOapT6
|
||||
NnVQFPvj2EqNgwYl+uhj6I6wPTbMfGkCIQCrQdJd7KhXgqvgSTlwD8hzZ9L7mC4a
|
||||
OHpHobt70G4W8QIhAI1/BGpzP7UPYbHsLRib2crHPkGIzte247ZMHIGCPE1xAiBc
|
||||
dDwTPfi5ZdAouUH+T4RCqgS5lrNSB4yah8LxFwFpVg==
|
||||
-----END RSA PRIVATE KEY-----
|
||||
""")
|
||||
key = jwk.JWKRSA.load(pem)
|
||||
|
||||
jwt = jws.JWS.sign(
|
||||
payload=payload,
|
||||
key=key,
|
||||
alg=jwa.RS256,
|
||||
kid='123',
|
||||
protect=frozenset(['alg', 'kid'])
|
||||
).to_compact()
|
||||
|
||||
eq_(0, AccountEvent.objects.count())
|
||||
|
||||
response = self.client.post(
|
||||
reverse('users.fxa_webhook'),
|
||||
content_type="",
|
||||
HTTP_AUTHORIZATION="Bearer " + jwt
|
||||
)
|
||||
eq_(400, response.status_code)
|
||||
eq_(0, AccountEvent.objects.count())
|
||||
|
||||
@setup_key
|
||||
def test_id_token(self, key):
|
||||
payload = json.dumps({
|
||||
"iss": "http://example.com",
|
||||
"sub": "54321",
|
||||
"aud": "12345",
|
||||
"iat": 1565720808,
|
||||
})
|
||||
|
||||
jwt = jws.JWS.sign(
|
||||
payload=payload,
|
||||
key=key,
|
||||
alg=jwa.RS256,
|
||||
kid='123',
|
||||
protect=frozenset(['alg', 'kid'])
|
||||
).to_compact()
|
||||
|
||||
eq_(0, AccountEvent.objects.count())
|
||||
|
||||
response = self.client.post(
|
||||
reverse('users.fxa_webhook'),
|
||||
content_type="",
|
||||
HTTP_AUTHORIZATION="Bearer " + jwt
|
||||
)
|
||||
eq_(400, response.status_code)
|
||||
eq_(0, AccountEvent.objects.count())
|
||||
|
|
|
@ -58,5 +58,6 @@ if settings.OIDC_ENABLE:
|
|||
name='users.fxa_authentication_init'),
|
||||
url(r'^fxa/logout/$', never_cache(views.FXALogoutView.as_view()),
|
||||
name='users.fxa_logout_url'),
|
||||
url(r'^fxa/events/?$', never_cache(views.WebhookView.as_view()), name='users.fxa_webhook'),
|
||||
url(r'^oidc/', include('mozilla_django_oidc.urls')),
|
||||
]
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import bisect
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group, User
|
||||
|
@ -74,6 +75,24 @@ def deactivate_user(user, moderator):
|
|||
deactivation.save()
|
||||
|
||||
|
||||
def anonymize_user(user):
|
||||
# Clear the profile
|
||||
profile = user.profile
|
||||
profile.clear()
|
||||
profile.fxa_uid = '{user_id}-{uid}'.format(user_id=user.id, uid=str(uuid4()))
|
||||
profile.save()
|
||||
|
||||
# Deactivate the user and change key information
|
||||
user.username = 'user%s' % user.id
|
||||
user.email = '%s@example.com' % user.id
|
||||
deactivate_user(user, user)
|
||||
|
||||
# Remove from all groups
|
||||
user.groups.clear()
|
||||
|
||||
user.save()
|
||||
|
||||
|
||||
def get_oidc_fxa_setting(attr):
|
||||
"""Helper method to return the appropriate setting for Firefox Accounts authentication."""
|
||||
FXA_CONFIGURATION = {
|
||||
|
|
|
@ -1,20 +1,25 @@
|
|||
import json
|
||||
from ast import literal_eval
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import auth, messages
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import (Http404, HttpResponseForbidden,
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import (Http404, HttpResponse, HttpResponseForbidden,
|
||||
HttpResponsePermanentRedirect, HttpResponseRedirect)
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import (require_GET, require_http_methods,
|
||||
require_POST)
|
||||
from django.views.generic import View
|
||||
# from axes.decorators import watch_login
|
||||
from mozilla_django_oidc.views import (OIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationRequestView,
|
||||
OIDCLogoutView)
|
||||
from tidings.models import Watch
|
||||
|
||||
from josepy.jwk import JWK
|
||||
from josepy.jws import JWS
|
||||
from kitsune import users as constants
|
||||
from kitsune.access.decorators import (login_required, logout_required,
|
||||
permission_required)
|
||||
|
@ -26,12 +31,23 @@ from kitsune.sumo.templatetags.jinja_helpers import urlparams
|
|||
from kitsune.sumo.urlresolvers import reverse
|
||||
from kitsune.sumo.utils import get_next_url, simple_paginate
|
||||
from kitsune.users.forms import ProfileForm, SettingsForm
|
||||
from kitsune.users.models import Deactivation, Profile, Setting
|
||||
from kitsune.users.models import AccountEvent, Deactivation, Profile, SET_ID_PREFIX
|
||||
from kitsune.users.templatetags.jinja_helpers import profile_url
|
||||
from kitsune.users.utils import (add_to_contributors, deactivate_user,
|
||||
get_oidc_fxa_setting)
|
||||
get_oidc_fxa_setting, anonymize_user)
|
||||
from kitsune.wiki.models import (user_documents, user_num_documents,
|
||||
user_redirects)
|
||||
from mozilla_django_oidc.utils import import_from_settings
|
||||
# from axes.decorators import watch_login
|
||||
from mozilla_django_oidc.views import (OIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationRequestView,
|
||||
OIDCLogoutView)
|
||||
from tidings.models import Watch
|
||||
from kitsune.users.tasks import (
|
||||
process_event_delete_user,
|
||||
process_event_password_change,
|
||||
process_event_subscription_state_change
|
||||
)
|
||||
|
||||
|
||||
@ssl_required
|
||||
|
@ -117,24 +133,7 @@ def profile(request, username):
|
|||
@login_required
|
||||
@require_POST
|
||||
def close_account(request):
|
||||
# Clear the profile
|
||||
user_id = request.user.id
|
||||
profile = get_object_or_404(Profile, user__id=user_id)
|
||||
profile.clear()
|
||||
profile.fxa_uid = '{user_id}-{uid}'.format(user_id=user_id, uid=str(uuid4()))
|
||||
profile.save()
|
||||
|
||||
# Deactivate the user and change key information
|
||||
request.user.username = 'user%s' % user_id
|
||||
request.user.email = '%s@example.com' % user_id
|
||||
request.user.is_active = False
|
||||
|
||||
# Remove from all groups
|
||||
request.user.groups.clear()
|
||||
# Remove all settings
|
||||
Setting.objects.filter(user=request.user).delete()
|
||||
|
||||
request.user.save()
|
||||
anonymize_user(request.user)
|
||||
|
||||
# Log the user out
|
||||
auth.logout(request)
|
||||
|
@ -350,3 +349,137 @@ class FXALogoutView(OIDCLogoutView):
|
|||
if val is not None:
|
||||
return val
|
||||
return super(FXALogoutView, FXALogoutView).get_settings(attr, *args)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class WebhookView(View):
|
||||
"""The flow here is based on the mozilla-django-oidc lib.
|
||||
|
||||
If/When the said lib supports SET tokens this will be replaced by the lib.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_settings(attr, *args):
|
||||
"""Override the logout url for Firefox Accounts."""
|
||||
|
||||
val = get_oidc_fxa_setting(attr)
|
||||
if val is not None:
|
||||
return val
|
||||
return import_from_settings(attr, *args)
|
||||
|
||||
def retrieve_matching_jwk(self, header):
|
||||
"""Get the signing key by exploring the JWKS endpoint of the OP."""
|
||||
|
||||
response_jwks = requests.get(
|
||||
self.get_settings('OIDC_OP_JWKS_ENDPOINT'),
|
||||
verify=self.get_settings('OIDC_VERIFY_SSL', True)
|
||||
)
|
||||
response_jwks.raise_for_status()
|
||||
jwks = response_jwks.json()
|
||||
|
||||
key = None
|
||||
for jwk in jwks['keys']:
|
||||
if jwk['kid'] != header.get('kid'):
|
||||
continue
|
||||
if 'alg' in jwk and jwk['alg'] != header['alg']:
|
||||
raise SuspiciousOperation('alg values do not match.')
|
||||
key = jwk
|
||||
if key is None:
|
||||
raise SuspiciousOperation('Could not find a valid JWKS.')
|
||||
return key
|
||||
|
||||
def verify_token(self, token, **kwargs):
|
||||
"""Validate the token signature."""
|
||||
|
||||
token = force_bytes(token)
|
||||
jws = JWS.from_compact(token)
|
||||
header = json.loads(jws.signature.protected)
|
||||
|
||||
try:
|
||||
header.get('alg')
|
||||
except KeyError:
|
||||
msg = 'No alg value found in header'
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
jwk_json = self.retrieve_matching_jwk(header)
|
||||
jwk = JWK.from_json(jwk_json)
|
||||
|
||||
if not jws.verify(jwk):
|
||||
msg = 'JWS token verification failed.'
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
# The 'token' will always be a byte string since it's
|
||||
# the result of base64.urlsafe_b64decode().
|
||||
# The payload is always the result of base64.urlsafe_b64decode().
|
||||
# In Python 3 and 2, that's always a byte string.
|
||||
# In Python3.6, the json.loads() function can accept a byte string
|
||||
# as it will automagically decode it to a unicode string before
|
||||
# deserializing https://bugs.python.org/issue17909
|
||||
return json.loads(jws.payload.decode('utf-8'))
|
||||
|
||||
def process_events(self, payload):
|
||||
"""Save the events in the db, and enqueue jobs to act upon them"""
|
||||
|
||||
fxa_uid = payload.get('sub')
|
||||
events = payload.get('events')
|
||||
try:
|
||||
profile_obj = Profile.objects.get(fxa_uid=fxa_uid)
|
||||
except Profile.DoesNotExist:
|
||||
profile_obj = None
|
||||
|
||||
for long_id, event in events.items():
|
||||
short_id = long_id.replace(SET_ID_PREFIX, '')
|
||||
|
||||
account_event = AccountEvent.objects.create(
|
||||
issued_at=payload['iat'],
|
||||
jwt_id=payload['jti'],
|
||||
fxa_uid=fxa_uid,
|
||||
status=AccountEvent.UNPROCESSED if profile_obj else AccountEvent.IGNORED,
|
||||
body=json.dumps(event),
|
||||
event_type=short_id,
|
||||
profile=profile_obj
|
||||
)
|
||||
|
||||
if profile_obj:
|
||||
if short_id == AccountEvent.DELETE_USER:
|
||||
process_event_delete_user.delay(account_event.id)
|
||||
elif short_id == AccountEvent.SUBSCRIPTION_STATE_CHANGE:
|
||||
process_event_subscription_state_change.delay(account_event.id)
|
||||
elif short_id == AccountEvent.PASSWORD_CHANGE:
|
||||
process_event_password_change.delay(account_event.id)
|
||||
else:
|
||||
account_event.status = AccountEvent.NOT_IMPLEMENTED
|
||||
account_event.save()
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
authorization = request.META.get('HTTP_AUTHORIZATION')
|
||||
if not authorization:
|
||||
raise Http404
|
||||
|
||||
auth = authorization.split()
|
||||
if auth[0].lower() != 'bearer':
|
||||
raise Http404
|
||||
id_token = auth[1]
|
||||
|
||||
payload = self.verify_token(id_token)
|
||||
|
||||
if payload:
|
||||
issuer = payload['iss']
|
||||
events = payload.get('events', '')
|
||||
fxa_uid = payload.get('sub', '')
|
||||
exp = payload.get('exp')
|
||||
|
||||
# If the issuer is not Firefox Accounts raise a 404 error
|
||||
if settings.FXA_SET_ISSUER != issuer:
|
||||
raise Http404
|
||||
|
||||
# If exp is in the token then it's an id_token that should not be here
|
||||
if any([not events,
|
||||
not fxa_uid,
|
||||
exp]):
|
||||
return HttpResponse(status=400)
|
||||
|
||||
self.process_events(payload)
|
||||
|
||||
return HttpResponse(status=202)
|
||||
raise Http404
|
||||
|
|
Загрузка…
Ссылка в новой задаче