-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1129 from uktrade/develop
Release to UAT/Prod
- Loading branch information
Showing
22 changed files
with
553 additions
and
37 deletions.
There are no files selected for viewing
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
from django.apps import AppConfig | ||
|
||
|
||
class ActivityStreamConfig(AppConfig): | ||
name = 'activitystream' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import logging | ||
|
||
from mohawk.exc import HawkFail | ||
from rest_framework.authentication import BaseAuthentication | ||
from rest_framework.exceptions import AuthenticationFailed | ||
|
||
from activitystream.helpers import authorise | ||
|
||
logger = logging.getLogger(__name__) | ||
NO_CREDENTIALS_MESSAGE = 'Authentication credentials were not provided.' | ||
INCORRECT_CREDENTIALS_MESSAGE = 'Incorrect authentication credentials.' | ||
|
||
|
||
class ActivityStreamAuthentication(BaseAuthentication): | ||
def authenticate_header(self, request): | ||
"""This is returned as the WWW-Authenticate header when | ||
AuthenticationFailed is raised. DRF also requires this | ||
to send a 401 (as opposed to 403) | ||
""" | ||
return 'Hawk' | ||
|
||
def authenticate(self, request): | ||
"""Authenticates a request using Hawk signature | ||
If either of these suggest we cannot authenticate, AuthenticationFailed | ||
is raised, as required in the DRF authentication flow | ||
""" | ||
|
||
return self.authenticate_by_hawk(request) | ||
|
||
def authenticate_by_hawk(self, request): | ||
if 'HTTP_AUTHORIZATION' not in request.META: | ||
raise AuthenticationFailed(NO_CREDENTIALS_MESSAGE) | ||
|
||
# Required to make mohawk auth work locally, and no harm in production, | ||
# so saves having to have it as a temporary monkey-patch | ||
request.META['CONTENT_TYPE'] = b'' | ||
|
||
try: | ||
hawk_receiver = authorise(request) | ||
except HawkFail as e: | ||
logger.warning('Failed authentication {e}'.format(e=e)) | ||
raise AuthenticationFailed(INCORRECT_CREDENTIALS_MESSAGE) | ||
|
||
return (None, hawk_receiver) | ||
|
||
|
||
class ActivityStreamHawkResponseMiddleware: | ||
"""Adds the Server-Authorization header to the response, so the originator | ||
of the request can authenticate the response | ||
""" | ||
|
||
def __init__(self, *args, **kwargs): | ||
pass | ||
|
||
def process_response(self, viewset, response): | ||
response['Server-Authorization'] = viewset.request.auth.respond( | ||
content=response.content, | ||
content_type=response['Content-Type'], | ||
) | ||
return response |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
from django_filters import NumberFilter, FilterSet | ||
from wagtail.models import Page | ||
|
||
|
||
class ActivityStreamCMSContentFilter(FilterSet): | ||
after = NumberFilter(method='filter_after') | ||
|
||
def filter_after(self, queryset, name, value): | ||
value = value or 0 | ||
return queryset.filter(id__gt=value) | ||
|
||
class Meta: | ||
model = Page | ||
fields = ['id'] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import logging | ||
|
||
from django.conf import settings | ||
from django.core.cache import cache | ||
from django.utils.crypto import constant_time_compare | ||
from mohawk import Receiver | ||
from mohawk.exc import HawkFail | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
def lookup_credentials(access_key_id): | ||
"""Raises a HawkFail if the passed ID is not equal to | ||
settings.ACTIVITY_STREAM_ACCESS_KEY_ID | ||
""" | ||
if not constant_time_compare(access_key_id, settings.ACTIVITY_STREAM_ACCESS_KEY_ID): | ||
raise HawkFail( | ||
'No Hawk ID of {access_key_id}'.format( | ||
access_key_id=access_key_id, | ||
) | ||
) | ||
|
||
return { | ||
'id': settings.ACTIVITY_STREAM_ACCESS_KEY_ID, | ||
'key': settings.ACTIVITY_STREAM_SECRET_ACCESS_KEY, | ||
'algorithm': 'sha256', | ||
} | ||
|
||
|
||
def seen_nonce(access_key_id, nonce, _): | ||
"""Returns if the passed access_key_id/nonce combination has been | ||
used within 60 seconds | ||
""" | ||
cache_key = 'activity_stream:{access_key_id}:{nonce}'.format( | ||
access_key_id=access_key_id, | ||
nonce=nonce, | ||
) | ||
|
||
# cache.add only adds key if it isn't present | ||
seen_cache_key = not cache.add( | ||
cache_key, | ||
True, | ||
timeout=60, | ||
) | ||
|
||
if seen_cache_key: | ||
logger.warning('Already seen nonce {nonce}'.format(nonce=nonce)) | ||
|
||
return seen_cache_key | ||
|
||
|
||
def authorise(request): | ||
"""Raises a HawkFail if the passed request cannot be authenticated""" | ||
return Receiver( | ||
lookup_credentials, | ||
request.META['HTTP_AUTHORIZATION'], | ||
request.build_absolute_uri(), | ||
request.method, | ||
content=request.body, | ||
content_type=request.content_type, | ||
seen_nonce=seen_nonce, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
from rest_framework import pagination | ||
from rest_framework.response import Response | ||
from rest_framework.utils.urls import replace_query_param | ||
|
||
|
||
class ActivityStreamBasePagination(pagination.BasePagination): | ||
page_query_param = 'after' | ||
|
||
def get_paginated_response(self, data): | ||
next_link = self.get_next_link() | ||
return Response( | ||
{ | ||
'@context': 'https://www.w3.org/ns/activitystreams', | ||
'type': 'Collection', | ||
'orderedItems': data, | ||
**next_link, | ||
} | ||
) | ||
|
||
def get_next_link(self): | ||
if self.has_next: | ||
url = self.request.build_absolute_uri() | ||
link = replace_query_param(url, self.page_query_param, self.next_value) | ||
return {'next': link} | ||
return {} | ||
|
||
|
||
class ActivityStreamCMSContentPagination(ActivityStreamBasePagination): | ||
page_size = 20 | ||
|
||
def paginate_queryset(self, queryset, request, view=None): | ||
self.has_next = queryset.count() > self.page_size | ||
page = list(queryset[: self.page_size]) | ||
self.next_value = page[-1].id if page else '' | ||
self.request = request | ||
return page |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
from rest_framework import serializers | ||
from core.cache import PageCache | ||
from django.conf import settings | ||
|
||
|
||
class WagtailPageSerializer(serializers.Serializer): | ||
def get_cms_content_for_obj(self, obj): | ||
try: | ||
result = dict( | ||
id='dit:cmsContent:international:' + str(obj.id), | ||
type='dit:cmsContent', | ||
title=obj.title, | ||
url=settings.APP_URL_GREAT_INTERNATIONAL+'content'+obj.url, | ||
seo_title=obj.seo_title, | ||
search_description=obj.search_description, | ||
first_published_at=obj.first_published_at.isoformat(), | ||
last_published_at=obj.last_published_at.isoformat(), | ||
content_type_id=obj.content_type_id, | ||
content=f"{PageCache.get(obj.id, lang='en-gb')}", | ||
) | ||
except Exception as e: | ||
result = {"error": f"Could not parse content for class {obj.specific_class}. Error: {e}"} | ||
|
||
return result | ||
|
||
def to_representation(self, obj): | ||
return { | ||
'id': ('dit:cmsContent:international:' + str(obj.id) + ':Update'), | ||
'type': 'Update', | ||
'published': obj.last_published_at.isoformat(), | ||
'object': self.get_cms_content_for_obj(obj), | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
from django.urls import path | ||
|
||
import activitystream.views | ||
|
||
app_name = 'activitystream' | ||
|
||
urlpatterns = [ | ||
path( | ||
r'cms-content/', | ||
activitystream.views.CMSContentActivityStreamView.as_view(), | ||
name='cms-content', | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
from rest_framework.generics import ListAPIView | ||
|
||
from activitystream.authentication import ( | ||
ActivityStreamAuthentication, | ||
ActivityStreamHawkResponseMiddleware, | ||
) | ||
from django.utils.decorators import decorator_from_middleware | ||
from django.db.models import Q | ||
from wagtail.models import Page | ||
import django_filters.rest_framework | ||
from activitystream.serializers import WagtailPageSerializer | ||
from activitystream.pagination import ActivityStreamCMSContentPagination | ||
from activitystream.filters import ActivityStreamCMSContentFilter | ||
|
||
|
||
class ActivityStreamBaseView(ListAPIView): | ||
authentication_classes = (ActivityStreamAuthentication,) | ||
permission_classes = () | ||
filter_backends = [django_filters.rest_framework.DjangoFilterBackend] | ||
|
||
@decorator_from_middleware(ActivityStreamHawkResponseMiddleware) | ||
def list(self, request, *args, **kwargs): | ||
"""A single page of activities to be consumed by activity stream.""" | ||
return super().list(request, *args, **kwargs) | ||
|
||
|
||
class CMSContentActivityStreamView(ActivityStreamBaseView): | ||
queryset = Page.objects.exclude(Q(live=False) | Q(first_published_at__isnull=True) | Q(last_published_at__isnull=True)) # noqa:E501 | ||
serializer_class = WagtailPageSerializer | ||
pagination_class = ActivityStreamCMSContentPagination | ||
filterset_class = ActivityStreamCMSContentFilter | ||
|
||
def get_queryset(self): | ||
return self.queryset.order_by('id') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import pytest | ||
from activitystream.filters import ActivityStreamCMSContentFilter | ||
from wagtail.models import Page | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_cms_content_filter(international_site): | ||
queryset = Page.objects.all() | ||
assert queryset.count() == 3 | ||
filtered = ActivityStreamCMSContentFilter().filter_after(queryset, '', 1) | ||
assert filtered.count() == 2 | ||
assert filtered[0].id == 2 | ||
assert filtered[1].id == 3 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import pytest | ||
from django.test import override_settings | ||
from mohawk.exc import HawkFail | ||
|
||
from activitystream.helpers import lookup_credentials, seen_nonce | ||
|
||
|
||
@pytest.mark.django_db | ||
@override_settings(ACTIVITY_STREAM_ACCESS_KEY_ID='good-key') | ||
def test_lookup_credentials__mismatching_key(): | ||
with pytest.raises(HawkFail): | ||
lookup_credentials('bad-key') | ||
|
||
|
||
@override_settings( | ||
CACHES={ | ||
'default': { | ||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', | ||
'KEY_PREFIX': 'test', | ||
} | ||
}, | ||
) | ||
@pytest.mark.django_db | ||
def test_seen_nonce__seen_before(caplog): | ||
assert not seen_nonce('access-key', '123-nonce-value', None) | ||
assert not caplog.records | ||
|
||
assert seen_nonce('access-key', '123-nonce-value', None) | ||
assert len(caplog.records) == 1 | ||
assert caplog.records[0].message == 'Already seen nonce 123-nonce-value' | ||
assert caplog.records[0].levelname == 'WARNING' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import pytest | ||
from activitystream.pagination import ActivityStreamCMSContentPagination | ||
from wagtail.models import Page | ||
from unittest.mock import MagicMock | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_get_next_link(international_site, monkeypatch): | ||
monkeypatch.setattr(ActivityStreamCMSContentPagination, 'page_size', 2) | ||
request = MagicMock() | ||
|
||
queryset = Page.objects.all() | ||
pagination_instance = ActivityStreamCMSContentPagination() | ||
pagination_instance.paginate_queryset(queryset, request) | ||
next_link = pagination_instance.get_next_link() | ||
|
||
assert '?after=2' in next_link['next'] |
Oops, something went wrong.