Skip to content

Commit

Permalink
Merge pull request #1129 from uktrade/develop
Browse files Browse the repository at this point in the history
Release to UAT/Prod
  • Loading branch information
timothyPatterson authored Oct 3, 2023
2 parents 9ea74d2 + 4fe0ff5 commit 4edca83
Show file tree
Hide file tree
Showing 22 changed files with 553 additions and 37 deletions.
Empty file added activitystream/__init__.py
Empty file.
5 changes: 5 additions & 0 deletions activitystream/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class ActivityStreamConfig(AppConfig):
name = 'activitystream'
61 changes: 61 additions & 0 deletions activitystream/authentication.py
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
14 changes: 14 additions & 0 deletions activitystream/filters.py
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']
62 changes: 62 additions & 0 deletions activitystream/helpers.py
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,
)
36 changes: 36 additions & 0 deletions activitystream/pagination.py
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
32 changes: 32 additions & 0 deletions activitystream/serializers.py
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),
}
13 changes: 13 additions & 0 deletions activitystream/urls.py
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',
),
]
34 changes: 34 additions & 0 deletions activitystream/views.py
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')
1 change: 1 addition & 0 deletions conf/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
'django_celery_beat',
'drf_spectacular',
'wagtailmarkdown',
'activitystream.apps.ActivityStreamConfig',
]

MIDDLEWARE = [
Expand Down
2 changes: 2 additions & 0 deletions conf/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import core.views
from groups.views import GroupInfoModalView
import activitystream.urls

api_router = WagtailAPIRouter('api')
api_router.register_endpoint('pages', core.views.PagesOptionalDraftAPIEndpoint)
Expand Down Expand Up @@ -50,6 +51,7 @@


urlpatterns = [
path('activity-stream/', include(activitystream.urls, namespace='activitystream')),
re_path(r'^django-admin/', admin.site.urls),
re_path(r'^api/', include((api_urls, 'api'))),
re_path(r'^healthcheck/', include((healthcheck_urls, 'healthcheck'))),
Expand Down
2 changes: 1 addition & 1 deletion requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ notifications-python-client==6.3.*
num2words==0.5.10
pycountry==19.8.18
elastic-apm>6.0
gevent==22.10.*
gevent==23.9.*
psycogreen==1.0.2
wagtailmedia==0.14.*
cryptography==41.*
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ click-repl==0.3.0
# via celery
cron-descriptor==1.4.0
# via django-celery-beat
cryptography==41.0.3
cryptography==41.0.4
# via -r requirements.in
directory-components==39.1.3
# via -r requirements.in
Expand Down Expand Up @@ -151,7 +151,7 @@ elastic-apm==6.18.0
# via -r requirements.in
et-xmlfile==1.1.0
# via openpyxl
gevent==22.10.2
gevent==23.9.1
# via -r requirements.in
greenlet==2.0.2
# via gevent
Expand Down
4 changes: 2 additions & 2 deletions requirements_test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ coveralls==3.3.1
# via -r requirements_test.in
cron-descriptor==1.4.0
# via django-celery-beat
cryptography==41.0.3
cryptography==41.0.4
# via -r requirements.in
directory-components==39.1.3
# via -r requirements.in
Expand Down Expand Up @@ -177,7 +177,7 @@ flake8==6.1.0
# via -r requirements_test.in
freezegun==0.3.14
# via -r requirements_test.in
gevent==22.10.2
gevent==23.9.1
# via -r requirements.in
gitdb==4.0.10
# via gitpython
Expand Down
Empty file.
13 changes: 13 additions & 0 deletions tests/activitystream/test_filters.py
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
31 changes: 31 additions & 0 deletions tests/activitystream/test_helpers.py
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'
17 changes: 17 additions & 0 deletions tests/activitystream/test_pagination.py
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']
Loading

0 comments on commit 4edca83

Please sign in to comment.