def test_seen_nonce_then_not_authenticated(self): url = 'http://my-domain:8080/v1/' header = hawk_auth_header('my-other-id', 'my-other-secret', url, 'POST', 'my-type', b'my-content') passed_nonce = None passed_id = None def seen_nonce_true(nonce, _id): nonlocal passed_nonce nonlocal passed_id passed_nonce = nonce passed_id = _id return True error, creds = authenticate_hawk_header( lookup_credentials, seen_nonce_true, 60, header, 'POST', 'my-domain', '8080', '/v1/', 'my-type', b'my-content', ) self.assertEqual(error, 'Invalid nonce') self.assertEqual(creds, None) self.assertEqual(passed_id, 'my-other-id') self.assertEqual( passed_nonce, dict(re.findall(r'([a-z]+)="([^"]+)"', header))['nonce'])
def test_invalid_header_format_then_not_authenticated(self): error, creds = authenticate_hawk_header( lookup_credentials, seen_nonce, 60, 'Hawk d', 'POST', 'my-domain', '8080', '/v1/', 'my-type', b'my-content', ) self.assertEqual(error, 'Invalid header') self.assertEqual(creds, None)
def test_bad_content_type_then_not_authenticated(self): url = 'http://my-domain:8080/v1/' header = hawk_auth_header('my-id', 'my-secret', url, 'POST', 'not-type', b'my-content') error, creds = authenticate_hawk_header( lookup_credentials, seen_nonce, 60, header, 'POST', 'my-domain', '8080', '/v1/', 'my-type', b'my-content', ) self.assertEqual(error, 'Invalid hash') self.assertEqual(creds, None)
def test_missing_nonce_then_not_authenticated(self): url = 'http://my-domain:8080/v1/' header = hawk_auth_header('my-id', 'my-secret', url, 'POST', 'not-type', b'my-content') bad_auth_header = re.sub(r', nonce="[^"]+"', '', header) error, creds = authenticate_hawk_header( lookup_credentials, seen_nonce, 60, bad_auth_header, 'POST', 'my-domain', '8080', '/v1/', 'my-type', b'my-content', ) self.assertEqual(error, 'Missing nonce') self.assertEqual(creds, None)
def test_invalid_ts_format_then_not_authenticated(self): url = 'http://my-domain:8080/v1/' header = hawk_auth_header('my-id', 'my-secret', url, 'POST', 'not-type', b'my-content') bad_auth_header = re.sub(r'ts="[^"]+"', 'ts="non-numeric"', header) error, creds = authenticate_hawk_header( lookup_credentials, seen_nonce, 60, bad_auth_header, 'POST', 'my-domain', '8080', '/v1/', 'my-type', b'my-content', ) self.assertEqual(error, 'Invalid ts') self.assertEqual(creds, None)
def test_time_skew_then_not_authenticated(self): url = 'http://127.0.0.1:8080/v1/' past = datetime.now() + timedelta(seconds=-61) with freeze_time(past): header = hawk_auth_header('my-id', 'my-secret', url, 'POST', 'my-type', b'my-content') error, creds = authenticate_hawk_header( lookup_credentials, seen_nonce, 60, header, 'POST', 'my-domain', '8080', '/v1', 'my-type', b'my-content', ) self.assertEqual(error, 'Stale ts') self.assertEqual(creds, None)
def test_correct_header_then_authenticated(self): url = 'http://my-domain:8080/v1/' header = hawk_auth_header('my-id', 'my-secret', url, 'POST', 'my-type', b'my-content') error, creds = authenticate_hawk_header( lookup_credentials, seen_nonce, 60, header, 'POST', 'my-domain', '8080', '/v1/', 'my-type', b'my-content', ) self.assertEqual(error, None) self.assertEqual(creds, { 'id': 'my-id', 'key': 'my-secret', })
def activity_stream(request): def forbidden(): return JsonResponse( data={}, status=403, ) ############################################ ## Ensure not accessed via public networking via_public_internet = "x-forwarded-for" in request.headers if via_public_internet: return forbidden() ########################### ## Ensure signed with Hawk def lookup_credentials(passed_id): user = { "id": settings.ACTIVITY_STREAM_HAWK_ID, "key": settings.ACTIVITY_STREAM_HAWK_SECRET, } return user if hmac.compare_digest(passed_id, user["id"]) else None def seen_nonce(nonce, id): # No replay attack prevention since no shared cache between instances, # but we're ok with that for return False try: request.headers["authorization"] except KeyError: return forbidden() # This is brittle to not running in PaaS or not via private networking host, port = request.META["HTTP_HOST"].split(":") max_skew_seconds = 15 error_message, credentials = authenticate_hawk_header( lookup_credentials, seen_nonce, max_skew_seconds, request.headers["authorization"], request.method, host, port, request.get_full_path(), request.headers.get("content-type", ""), request.body, ) if error_message is not None: return forbidden() ############# ## Get cursor after_ts_str, after_user_id_str = request.GET.get( "cursor", "0.0_00000000-0000-4000-0000-000000000000").split("_") after_ts = datetime.datetime.fromtimestamp(float(after_ts_str)) after_user_id = uuid.UUID(after_user_id_str) ########################################################## ## Fetch activities after cursor (i.e. user modifications) # The one_second_ago is since`last_modified` is not strictly monotonically increasing due to # overlapping transactions committed in a non-guarenteed order. It's technically an eventually # consistent situation, so updates can be missed if serveral are close together. We mitigate # this risk by adding a delay before activities appear in the stream. one_second_ago = datetime.datetime.now() - datetime.timedelta(seconds=1) per_page = 50 User = get_user_model() users = list( User.objects.only( "user_id", "email_user_id", "last_modified", "last_accessed", "is_active", "first_name", "last_name", "email", "contact_email", "date_joined", ).prefetch_related( "emails", "permitted_applications", "access_profiles__oauth2_applications", "access_profiles__saml2_applications", ).alias( # TextField is a bit of hack: Django requires some output_field, and there doesn't # seem to be a "composite" field type available. However, since this value isn't # accessed by Python code, it doesn't seem to matter what sort of field is used last_modified_user_id=Func(F('last_modified'), F('user_id'), function='Row', output_field=TextField())).filter( last_modified_user_id__gt=Func( after_ts, after_user_id, function='Row'), last_modified__lt=one_second_ago, ).order_by("last_modified", "user_id")[:per_page]) ################################################################ ## Convert to activities, with link to next page if at least one def next_url(after_ts, after_user_id): return request.build_absolute_uri( reverse("api-v1:core:activity-stream")) + "?cursor={}_{}".format( str(after_ts.timestamp()), str(after_user_id)) def without_duplicates(seq): seen = set() return [x for x in seq if not (x in seen or seen.add(x))] default_access_apps = OAuthApplication.get_default_access_applications() page = { "@context": [ "https://www.w3.org/ns/activitystreams", { "dit": "https://www.trade.gov.uk/ns/activitystreams/v1" }, ], "type": "Collection", "orderedItems": [ { "id": f"dit:StaffSSO:User:{user.user_id}:Update", "published": user.last_modified, "object": { "id": f"dit:StaffSSO:User:{user.user_id}", "type": "dit:StaffSSO:User", "name": user.get_full_name(), "dit:StaffSSO:User:userId": user.user_id, "dit:StaffSSO:User:emailUserId": user.email_user_id, "dit:StaffSSO:User:contactEmailAddress": user.contact_email if user.contact_email else None, "dit:StaffSSO:User:joined": user.date_joined, "dit:StaffSSO:User:lastAccessed": user.last_accessed, "dit:StaffSSO:User:permittedApplications": [ { # name and url are both in the W3C Activity Streams 2.0 Vocab "name": app["name"], "url": app["url"], } for app in user.get_permitted_applications( include_non_public=True, get_default_access_allowed_apps=lambda: default_access_apps, ) ], "dit:StaffSSO:User:status": "active" if user.is_active else "inactive", "dit:StaffSSO:User:becameInactiveOn": None if user.is_active else user.became_inactive_on, "dit:firstName": user.first_name, "dit:lastName": user.last_name, "dit:emailAddress": without_duplicates( [user.email] + sorted([email.email for email in user.emails.all()])), }, } for user in users ], **({ "next": next_url(users[-1].last_modified, users[-1].user_id) } if users else {}), } return JsonResponse( data=page, status=200, )