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:
Leo McArdle 2020-06-02 10:52:40 +01:00 коммит произвёл GitHub
Родитель d6059faba3
Коммит ca14ac88a2
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
16 изменённых файлов: 940 добавлений и 39 удалений

Просмотреть файл

@ -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"]

64
kitsune/users/tasks.py Normal file
Просмотреть файл

@ -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