示例#1
0
    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'])
示例#2
0
 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)
示例#3
0
    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)
示例#4
0
    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)
示例#5
0
    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)
示例#6
0
    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)
示例#7
0
    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',
        })
示例#8
0
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,
    )