From e7209c6e5004cb1de19fe2d40e444c101c00c8c8 Mon Sep 17 00:00:00 2001 From: Henrik Levkowetz Date: Sat, 16 Dec 2017 18:37:52 +0000 Subject: [PATCH] Added a new personal event table to keep track of personal API key logins, and a management command to send out reports about activity to users with API keys. Added a weekly cronjob script to trigger weekly reports, and a monthly script for future use. Added a @require_api_key decorator to validate API keys for API key views and log in the API key owner. Modified the API key management urls to use create and disable rather than add and delete. Updated the API key list view. Added an API placeholder view function for ballot position setting, for test purposes. Added tests for the decorator and management command. - Legacy-Id: 14426 --- bin/monthly | 15 +++ bin/weekly | 20 +++ ietf/api/urls.py | 2 + ietf/doc/views_ballot.py | 21 ++- .../commands/send_apikey_usage_emails.py | 51 ++++++++ ietf/ietfauth/tests.py | 122 +++++++++++++++--- ietf/ietfauth/urls.py | 4 +- ietf/ietfauth/views.py | 13 +- ietf/person/admin.py | 14 +- ietf/person/migrations/0021_personalapikey.py | 56 ++++++++ ietf/person/models.py | 33 ++++- ietf/person/resources.py | 40 +++++- ietf/settings.py | 2 +- ietf/templates/ietfauth/apikeys.html | 16 ++- ietf/templates/utils/apikey_usage_report.txt | 14 ++ ietf/utils/decorators.py | 83 ++++++------ 16 files changed, 425 insertions(+), 81 deletions(-) create mode 100755 bin/monthly create mode 100755 bin/weekly create mode 100644 ietf/ietfauth/management/commands/send_apikey_usage_emails.py create mode 100644 ietf/person/migrations/0021_personalapikey.py create mode 100644 ietf/templates/utils/apikey_usage_report.txt diff --git a/bin/monthly b/bin/monthly new file mode 100755 index 000000000..5c15827e1 --- /dev/null +++ b/bin/monthly @@ -0,0 +1,15 @@ +#!/bin/bash + +# Weekly datatracker jobs. +# +# This script is expected to be triggered by cron from +# /etc/cron.d/datatracker + +DTDIR=/a/www/ietf-datatracker/web +cd $DTDIR/ + +# Set up the virtual environment +source $DTDIR/env/bin/activate + +logger -p user.info -t cron "Running $DTDIR/bin/monthly" + diff --git a/bin/weekly b/bin/weekly new file mode 100755 index 000000000..e5ab321fb --- /dev/null +++ b/bin/weekly @@ -0,0 +1,20 @@ +#!/bin/bash + +# Weekly datatracker jobs. +# +# This script is expected to be triggered by cron from +# /etc/cron.d/datatracker + +DTDIR=/a/www/ietf-datatracker/web +cd $DTDIR/ + +# Set up the virtual environment +source $DTDIR/env/bin/activate + +logger -p user.info -t cron "Running $DTDIR/bin/weekly" + + +# Send out weekly summaries of apikey usage + +$DTDIR/ietf/manage.py send_apikey_usage_emails + diff --git a/ietf/api/urls.py b/ietf/api/urls.py index 7cb85e8f4..1c2660137 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -3,6 +3,7 @@ from django.conf.urls import include from ietf import api +from ietf.doc import views_ballot from ietf.meeting import views as meeting_views from ietf.submit import views as submit_views from ietf.utils.urls import url @@ -15,6 +16,7 @@ urlpatterns = [ # Custom API endpoints url(r'^notify/meeting/import_recordings/(?P[a-z0-9-]+)/?$', meeting_views.api_import_recordings), url(r'^submit/?$', submit_views.api_submit), + url(r'^iesg/position', views_ballot.api_set_position), ] # Additional (standard) Tastypie endpoints for n,a in api._api_list: diff --git a/ietf/doc/views_ballot.py b/ietf/doc/views_ballot.py index 761c3f870..e21221210 100644 --- a/ietf/doc/views_ballot.py +++ b/ietf/doc/views_ballot.py @@ -3,12 +3,14 @@ import datetime, json -from django.http import HttpResponseForbidden, HttpResponseRedirect, Http404 -from django.shortcuts import render, get_object_or_404, redirect -from django.urls import reverse as urlreverse -from django.template.loader import render_to_string from django import forms from django.conf import settings +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, Http404 +from django.shortcuts import render, get_object_or_404, redirect +from django.template.loader import render_to_string +from django.urls import reverse as urlreverse +from django.views.decorators.csrf import csrf_exempt + import debug # pyflakes:ignore @@ -23,12 +25,13 @@ from ietf.doc.mails import ( email_ballot_deferred, email_ballot_undeferred, from ietf.doc.lastcall import request_last_call from ietf.iesg.models import TelechatDate from ietf.ietfauth.utils import has_role, role_required, is_authorized_in_doc_stream +from ietf.mailtrigger.utils import gather_address_lists +from ietf.mailtrigger.forms import CcSelectForm from ietf.message.utils import infer_message from ietf.name.models import BallotPositionName from ietf.person.models import Person from ietf.utils.mail import send_mail_text, send_mail_preformatted -from ietf.mailtrigger.utils import gather_address_lists -from ietf.mailtrigger.forms import CcSelectForm +from ietf.utils.decorators import require_user_api_key BALLOT_CHOICES = (("yes", "Yes"), ("noobj", "No Objection"), @@ -233,6 +236,12 @@ def edit_position(request, name, ballot_id): blocking_positions=json.dumps(blocking_positions), )) +@require_user_api_key +@role_required('Area Director', 'Secretariat') +@csrf_exempt +def api_set_position(request): + return HttpResponse("Done", status=200, content_type='text/plain') + @role_required('Area Director','Secretariat') def send_ballot_comment(request, name, ballot_id): diff --git a/ietf/ietfauth/management/commands/send_apikey_usage_emails.py b/ietf/ietfauth/management/commands/send_apikey_usage_emails.py new file mode 100644 index 000000000..2718ef02a --- /dev/null +++ b/ietf/ietfauth/management/commands/send_apikey_usage_emails.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Copyright The IETF Trust 2017, All Rights Reserved +from __future__ import print_function, unicode_literals + +import datetime + +from textwrap import dedent + +from django.conf import settings +from django.core.management.base import BaseCommand + +import debug # pyflakes:ignore + +from ietf.person.models import PersonalApiKey, PersonApiKeyEvent +from ietf.utils.mail import send_mail + + +class Command(BaseCommand): + """ + Send out emails to all persons who have personal API keys about usage. + + Usage is show over the given period, where the default period is 7 days. + """ + + help = dedent(__doc__).strip() + + def add_arguments(self, parser): + parser.add_argument('-d', '--days', dest='days', type=int, default=7, + help='The period over which to show usage.') + + def handle(self, *filenames, **options): + """ + """ + + self.verbosity = int(options.get('verbosity')) + days = options.get('days') + + keys = PersonalApiKey.objects.filter(valid=True) + for key in keys: + earliest = datetime.datetime.now() - datetime.timedelta(days=days) + events = PersonApiKeyEvent.objects.filter(key=key, time__gt=earliest) + count = events.count() + events = events[:32] + if count: + key_name = key.hash()[:8] + subject = "API key usage for key '%s' for the last %s days" %(key_name, days) + to = key.person.email_address() + frm = settings.DEFAULT_FROM_EMAIL + send_mail(None, to, frm, subject, 'utils/apikey_usage_report.txt', {'person':key.person, + 'days':days, 'key':key, 'key_name':key_name, 'count':count, 'events':events, } ) + diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index f261c3075..34d587298 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -17,9 +17,10 @@ from ietf.utils.test_utils import TestCase, login_testing_unauthorized, uniconte from ietf.utils.test_data import make_test_data, make_review_data from ietf.utils.mail import outbox, empty_outbox from ietf.group.models import Group, Role, RoleName +from ietf.group.factories import GroupFactory from ietf.ietfauth.htpasswd import update_htpasswd_file from ietf.mailinglists.models import Subscribed -from ietf.person.models import Person, Email +from ietf.person.models import Person, Email, PersonalApiKey, PERSON_API_KEY_ENDPOINTS from ietf.person.factories import PersonFactory from ietf.review.models import ReviewWish, UnavailablePeriod from ietf.utils.decorators import skip_coverage @@ -497,7 +498,7 @@ class IetfAuthTests(TestCase): self.assertEqual(prev, user) self.assertTrue(user.check_password(u'password')) - def test_apikey(self): + def test_apikey_management(self): person = PersonFactory() url = urlreverse('ietf.ietfauth.views.apikey_index') @@ -511,33 +512,33 @@ class IetfAuthTests(TestCase): self.assertContains(r, 'Get a new personal API key') # Check the add key form content - url = urlreverse('ietf.ietfauth.views.apikey_add') + url = urlreverse('ietf.ietfauth.views.apikey_create') r = self.client.get(url) self.assertContains(r, 'Create a new personal API key') self.assertContains(r, 'Endpoint') # Add 2 keys - r = self.client.post(url, {'endpoint': '/api/submit'}) - self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index')) - r = self.client.post(url, {'endpoint': '/api/iesg/discuss'}) - self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index')) + for endpoint, display in PERSON_API_KEY_ENDPOINTS: + r = self.client.post(url, {'endpoint': endpoint}) + self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index')) # Check api key list content url = urlreverse('ietf.ietfauth.views.apikey_index') r = self.client.get(url) - self.assertContains(r, '/api/submit') - self.assertContains(r, '/api/iesg/discuss') + for endpoint, display in PERSON_API_KEY_ENDPOINTS: + self.assertContains(r, endpoint) q = PyQuery(r.content) - self.assertEqual(len(q('td code')), 2) + self.assertEqual(len(q('td code')), len(PERSON_API_KEY_ENDPOINTS)) # hash + self.assertEqual(len(q('td a:contains("Disable")')), len(PERSON_API_KEY_ENDPOINTS)) # Get one of the keys key = person.apikeys.first() - # Check the delete key form content - url = urlreverse('ietf.ietfauth.views.apikey_del') + # Check the disable key form content + url = urlreverse('ietf.ietfauth.views.apikey_disable') r = self.client.get(url) - self.assertContains(r, 'Delete a personal API key') + self.assertContains(r, 'Disable a personal API key') self.assertContains(r, 'Key') # Delete a key @@ -547,7 +548,98 @@ class IetfAuthTests(TestCase): # Check the api key list content again url = urlreverse('ietf.ietfauth.views.apikey_index') r = self.client.get(url) - self.assertNotContains(r, key.endpoint) q = PyQuery(r.content) - self.assertEqual(len(q('td code')), 1) + self.assertEqual(len(q('td code')), len(PERSON_API_KEY_ENDPOINTS)) # key hash + self.assertEqual(len(q('td a:contains("Disable")')), len(PERSON_API_KEY_ENDPOINTS)-1) + + def test_apikey_usage(self): + BAD_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + person = PersonFactory() + area = GroupFactory(type_id='area') + area.role_set.create(name_id='ad', person=person, email=person.email()) + + url = urlreverse('ietf.ietfauth.views.apikey_create') + # Check that the url is protected, then log in + login_testing_unauthorized(self, person.user.username, url) + + # Add keys + for endpoint, display in PERSON_API_KEY_ENDPOINTS: + r = self.client.post(url, {'endpoint': endpoint}) + self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index')) + + for key in person.apikeys.all()[:3]: + url = key.endpoint + + # successful access + r = self.client.post(url, {'apikey':key.hash(), 'dummy':'dummy',}) + self.assertEqual(r.status_code, 200) + + # bad method + r = self.client.put(url, {'apikey':key.hash()}) + self.assertEqual(r.status_code, 405) + + # missing apikey + r = self.client.post(url, {'dummy':'dummy',}) + self.assertEqual(r.status_code, 400) + self.assertIn('Missing apikey parameter', unicontent(r)) + + # invalid apikey + r = self.client.post(url, {'apikey':BAD_KEY, 'dummy':'dummy',}) + self.assertEqual(r.status_code, 400) + self.assertIn('Invalid apikey', unicontent(r)) + + # too long since regular login + person.user.last_login = datetime.datetime.now() - datetime.timedelta(days=settings.UTILS_APIKEY_GUI_LOGIN_LIMIT_DAYS+1) + person.user.save() + r = self.client.post(url, {'apikey':key.hash(), 'dummy':'dummy',}) + self.assertEqual(r.status_code, 400) + self.assertIn('Too long since last regular login', unicontent(r)) + person.user.last_login = datetime.datetime.now() + person.user.save() + + # endpoint mismatch + key2 = PersonalApiKey.objects.create(person=person, endpoint='/') + r = self.client.post(url, {'apikey':key2.hash(), 'dummy':'dummy',}) + self.assertEqual(r.status_code, 400) + self.assertIn('Apikey endpoint mismatch', unicontent(r)) + key2.delete() + + def test_send_apikey_report(self): + from ietf.ietfauth.management.commands.send_apikey_usage_emails import Command + from ietf.utils.mail import outbox, empty_outbox + + person = PersonFactory() + + url = urlreverse('ietf.ietfauth.views.apikey_create') + # Check that the url is protected, then log in + login_testing_unauthorized(self, person.user.username, url) + + # Add keys + for endpoint, display in PERSON_API_KEY_ENDPOINTS: + r = self.client.post(url, {'endpoint': endpoint}) + self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index')) + + # Use the endpoints (the form content will not be acceptable, but the + # apikey usage will be registered) + count = 2 + # avoid usage across dates + if datetime.datetime.now().time() > datetime.time(hour=23, minute=59, second=58): + time.sleep(2) + for i in range(count): + for key in person.apikeys.all(): + url = key.endpoint + self.client.post(url, {'apikey':key.hash(), 'dummy': 'dummy', }) + date = str(datetime.date.today()) + + empty_outbox() + cmd = Command() + cmd.handle(verbosity=0, days=7) + + self.assertEqual(len(outbox), len(PERSON_API_KEY_ENDPOINTS)) + for mail in outbox: + body = mail.get_payload() + self.assertIn("API key usage", mail['subject']) + self.assertIn(" %s times" % count, body) + self.assertIn(date, body) diff --git a/ietf/ietfauth/urls.py b/ietf/ietfauth/urls.py index b586d284b..fb1f84db5 100644 --- a/ietf/ietfauth/urls.py +++ b/ietf/ietfauth/urls.py @@ -8,8 +8,8 @@ from ietf.utils.urls import url urlpatterns = [ url(r'^$', views.index), url(r'^apikey/?$', views.apikey_index), - url(r'^apikey/add/?$', views.apikey_add), - url(r'^apikey/del/?$', views.apikey_del), + url(r'^apikey/add/?$', views.apikey_create), + url(r'^apikey/del/?$', views.apikey_disable), url(r'^confirmnewemail/(?P[^/]+)/$', views.confirm_new_email), url(r'^create/$', views.create_account), url(r'^create/confirm/(?P[^/]+)/$', views.confirm_account), diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index 99f4249bd..e3f549279 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -602,7 +602,7 @@ def apikey_index(request): @login_required @person_required -def apikey_add(request): +def apikey_create(request): class ApiKeyForm(forms.ModelForm): class Meta: model = PersonalApiKey @@ -623,7 +623,7 @@ def apikey_add(request): @login_required @person_required -def apikey_del(request): +def apikey_disable(request): person = request.user.person choices = [ (k.hash(), str(k)) for k in person.apikeys.all() ] # @@ -642,11 +642,12 @@ def apikey_del(request): if form.is_valid(): hash = form.data['hash'] key = PersonalApiKey.validate_key(hash) - key.delete() - messages.success(request, "Deleted key %s" % hash) + key.valid = False + key.save() + messages.success(request, "Disabled key %s" % hash) return redirect('ietf.ietfauth.views.apikey_index') else: - messages.error(request, "Key validation failed; key not deleted") + messages.error(request, "Key validation failed; key not disabled") else: form = KeyDeleteForm(request.GET) - return render(request, 'form.html', {'form':form, 'title':"Delete a personal API key", 'description':'', 'button':'Delete key'}) + return render(request, 'form.html', {'form':form, 'title':"Disable a personal API key", 'description':'', 'button':'Disable key'}) diff --git a/ietf/person/admin.py b/ietf/person/admin.py index 8659ac3f1..86c0c851f 100644 --- a/ietf/person/admin.py +++ b/ietf/person/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin -from ietf.person.models import Email, Alias, Person, PersonHistory, PersonalApiKey +from ietf.person.models import Email, Alias, Person, PersonHistory, PersonalApiKey, PersonEvent, PersonApiKeyEvent from ietf.person.name import name_parts class EmailAdmin(admin.ModelAdmin): @@ -49,3 +49,15 @@ class PersonalApiKeyAdmin(admin.ModelAdmin): raw_id_fields = ['person', ] search_fields = ['person__name', ] admin.site.register(PersonalApiKey, PersonalApiKeyAdmin) + +class PersonEventAdmin(admin.ModelAdmin): + list_display = ["id", "person", "time", "type", ] + search_fields = ["person__name", ] + raw_id_fields = ['person', ] +admin.site.register(PersonEvent, PersonEventAdmin) + +class PersonApiKeyEventAdmin(admin.ModelAdmin): + list_display = ["id", "person", "time", "type", "key"] + search_fields = ["person__name", ] + raw_id_fields = ['person', ] +admin.site.register(PersonApiKeyEvent, PersonApiKeyEventAdmin) diff --git a/ietf/person/migrations/0021_personalapikey.py b/ietf/person/migrations/0021_personalapikey.py new file mode 100644 index 000000000..e3e9e4bc3 --- /dev/null +++ b/ietf/person/migrations/0021_personalapikey.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.8 on 2017-12-15 08:19 +from __future__ import unicode_literals + +import datetime +from django.db import migrations, models +import django.db.models.deletion +import ietf.person.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('person', '0020_auto_20170701_0325'), + ] + + operations = [ + migrations.CreateModel( + name='PersonalApiKey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('endpoint', models.CharField(choices=[(b'/api/submit', b'/api/submit'), (b'/api/iesg/position', b'/api/iesg/position')], max_length=128)), + ('created', models.DateTimeField(default=datetime.datetime.now)), + ('valid', models.BooleanField(default=True)), + ('salt', models.BinaryField(default=ietf.person.models.salt, max_length=12)), + ('count', models.IntegerField(default=0)), + ('latest', models.DateTimeField(blank=True, null=True)), + ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='apikeys', to='person.Person')), + ], + ), + migrations.CreateModel( + name='PersonEvent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField(default=datetime.datetime.now, help_text=b'When the event happened')), + ('type', models.CharField(choices=[(b'apikey_login', b'API key login')], max_length=50)), + ('desc', models.TextField()), + ], + options={ + 'ordering': ['-time', '-id'], + }, + ), + migrations.CreateModel( + name='PersonApiKeyEvent', + fields=[ + ('personevent_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='person.PersonEvent')), + ('key', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='person.PersonalApiKey')), + ], + bases=('person.personevent',), + ), + migrations.AddField( + model_name='personevent', + name='person', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='person.Person'), + ), + ] diff --git a/ietf/person/models.py b/ietf/person/models.py index 8e640152f..16c1ee9c8 100644 --- a/ietf/person/models.py +++ b/ietf/person/models.py @@ -284,14 +284,14 @@ KEY_STRUCT = "i12s32s" def salt(): return uuid.uuid4().bytes[:12] -API_KEY_ENDPOINTS = [ - ("/api/submit", "/api/submit"), - ("/api/iesg/discuss", "/api/iesg/discuss"), +# Manual maintenance: List all endpoints that use @require_user_api_key here +PERSON_API_KEY_ENDPOINTS = [ + ("/api/iesg/position", "/api/iesg/position"), ] class PersonalApiKey(models.Model): person = models.ForeignKey(Person, related_name='apikeys') - endpoint = models.CharField(max_length=128, null=False, blank=False, choices=API_KEY_ENDPOINTS) + endpoint = models.CharField(max_length=128, null=False, blank=False, choices=PERSON_API_KEY_ENDPOINTS) created = models.DateTimeField(default=datetime.datetime.now, null=False) valid = models.BooleanField(default=True) salt = models.BinaryField(default=salt, max_length=12, null=False, blank=False) @@ -303,7 +303,10 @@ class PersonalApiKey(models.Model): import struct, hashlib, base64 key = base64.urlsafe_b64decode(six.binary_type(s)) id, salt, hash = struct.unpack(KEY_STRUCT, key) - k = cls.objects.get(id=id) + k = cls.objects.filter(id=id) + if not k.exists(): + return None + k = k.first() check = hashlib.sha256() for v in (str(id), str(k.person.id), k.created.isoformat(), k.endpoint, str(k.valid), salt, settings.SECRET_KEY): check.update(v) @@ -322,3 +325,23 @@ class PersonalApiKey(models.Model): def __unicode__(self): return "%s (%s): %s ..." % (self.endpoint, self.created.strftime("%Y-%m-%d %H:%M"), self.hash()[:16]) + +PERSON_EVENT_CHOICES = [ + ("apikey_login", "API key login"), + ] + +class PersonEvent(models.Model): + person = models.ForeignKey(Person) + time = models.DateTimeField(default=datetime.datetime.now, help_text="When the event happened") + type = models.CharField(max_length=50, choices=PERSON_EVENT_CHOICES) + desc = models.TextField() + + def __unicode__(self): + return u"%s %s at %s" % (self.person.plain_name(), self.get_type_display().lower(), self.time) + + class Meta: + ordering = ['-time', '-id'] + +class PersonApiKeyEvent(PersonEvent): + key = models.ForeignKey(PersonalApiKey) + diff --git a/ietf/person/resources.py b/ietf/person/resources.py index 2d23c9069..4cd0ce24a 100644 --- a/ietf/person/resources.py +++ b/ietf/person/resources.py @@ -6,7 +6,7 @@ from tastypie.cache import SimpleCache from ietf import api -from ietf.person.models import (Person, Email, Alias, PersonHistory, PersonalApiKey) +from ietf.person.models import (Person, Email, Alias, PersonHistory, PersonalApiKey, PersonEvent, PersonApiKeyEvent) from ietf.utils.resources import UserResource @@ -102,3 +102,41 @@ class PersonalApiKeyResource(ModelResource): "person": ALL_WITH_RELATIONS, } api.person.register(PersonalApiKeyResource()) + + +class PersonEventResource(ModelResource): + person = ToOneField(PersonResource, 'person') + class Meta: + queryset = PersonEvent.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'personevent' + filtering = { + "id": ALL, + "time": ALL, + "type": ALL, + "desc": ALL, + "person": ALL_WITH_RELATIONS, + } +api.person.register(PersonEventResource()) + + +class PersonApiKeyEventResource(ModelResource): + person = ToOneField(PersonResource, 'person') + personevent_ptr = ToOneField(PersonEventResource, 'personevent_ptr') + key = ToOneField(PersonalApiKeyResource, 'key') + class Meta: + queryset = PersonApiKeyEvent.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'personapikeyevent' + filtering = { + "id": ALL, + "time": ALL, + "type": ALL, + "desc": ALL, + "person": ALL_WITH_RELATIONS, + "personevent_ptr": ALL_WITH_RELATIONS, + "key": ALL_WITH_RELATIONS, + } +api.person.register(PersonApiKeyEventResource()) diff --git a/ietf/settings.py b/ietf/settings.py index 80a583b6a..5cb031c77 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -932,7 +932,7 @@ SILENCED_SYSTEM_CHECKS = [ STATS_NAMES_LIMIT = 25 UTILS_TEST_RANDOM_STATE_FILE = '.factoryboy_random_state' - +UTILS_APIKEY_GUI_LOGIN_LIMIT_DAYS = 30 # Put the production SECRET_KEY in settings_local.py, and also any other # sensitive or site-specific changes. DO NOT commit settings_local.py to svn. diff --git a/ietf/templates/ietfauth/apikeys.html b/ietf/templates/ietfauth/apikeys.html index c6cd30d8d..e4fe20ed4 100644 --- a/ietf/templates/ietfauth/apikeys.html +++ b/ietf/templates/ietfauth/apikeys.html @@ -20,16 +20,20 @@ {% for key in person.apikeys.all %} {% if forloop.first %} - + {% endif %} - - + + + - + + + @@ -38,7 +42,7 @@ {% endfor %}
KeyCreatedEndpointValid 
EndpointCreatedLatest useCountValid
{{ key.hash }}{{ key.created }} {{ key.endpoint }} {{ key.created }} {{ key.latest }} {{ key.count }} {{ key.valid }} +
{{ key.hash }} {% if key.valid %} - Delete + Disable {% endif %}
- Get a new personal API key + Get a new personal API key diff --git a/ietf/templates/utils/apikey_usage_report.txt b/ietf/templates/utils/apikey_usage_report.txt new file mode 100644 index 000000000..a3b7bfd7b --- /dev/null +++ b/ietf/templates/utils/apikey_usage_report.txt @@ -0,0 +1,14 @@ +{% load ietf_filters %}{% filter wordwrap:78 %} +Dear {{ person.plain_name }}, + +This is a summary of API key usage during the last {{ days }} days for the key '{{ key_name }}', +created {{ key.created }} for endpoint {{ key.endpoint }}. + +This API key was used {{ count }} times during this period. Showing {{ events|length }} access times: +{% for e in events %} + {{ e.time }}{% endfor %} + + +Best regards, +The IETF Secretariat +{% endfilter %} diff --git a/ietf/utils/decorators.py b/ietf/utils/decorators.py index 666508870..7c8ac3a1e 100644 --- a/ietf/utils/decorators.py +++ b/ietf/utils/decorators.py @@ -1,10 +1,18 @@ # Copyright The IETF Trust 2016, All Rights Reserved +import datetime + from decorator import decorator from django.conf import settings +from django.contrib.auth import login +from django.http import HttpResponse +from django.shortcuts import render -from test_runner import set_coverage_checking +import debug # pyflakes:ignore + +from ietf.utils.test_runner import set_coverage_checking +from ietf.person.models import Person, PersonalApiKey, PersonApiKeyEvent @decorator def skip_coverage(f, *args, **kwargs): @@ -18,8 +26,6 @@ def skip_coverage(f, *args, **kwargs): @decorator def person_required(f, request, *args, **kwargs): - from ietf.person.models import Person - from django.shortcuts import render if not request.user.is_authenticated: raise ValueError("The @person_required decorator should be called after @login_required.") try: @@ -27,43 +33,44 @@ def person_required(f, request, *args, **kwargs): except Person.DoesNotExist: return render(request, 'registration/missing_person.html') return f(request, *args, **kwargs) - + @decorator -def verify_user_api_key(f, request, *args, **kwargs): - from ietf.person.models import Person, PersonalApiKey - from django.shortcuts import render - if not request.user.is_authenticated: - raise ValueError("The @verify_user_api_key decorator should be called after @login_required.") - try: - person = request.user.person - except Person.DoesNotExist: - return render(request, 'registration/missing_person.html') +def require_user_api_key(f, request, *args, **kwargs): + + def err(code, text): + return HttpResponse(text, status=code, content_type='text/plain') + # Check method and get hash if request.method == 'POST': - hash = request.POST['apikey'] + hash = request.POST.get('apikey') elif request.method == 'GET': - hash = request.GET['apikey'] + hash = request.GET.get('apikey') else: - return render(request, 'base.html', { - 'content': """ -

Missing API key

- -

- There is no apikey provided with this call. - Please create a valid Personal API key and use that with your request. -

- """, - }) + return err(405, "Method not allowed") + if not hash: + return err(400, "Missing apikey parameter") + # Check hash key = PersonalApiKey.validate_key(hash) - if key and key.person == person: - return f(request, key, *args, **kwargs) - else: - return render(request, 'base.html', { - 'content': """ -

Bad API key

- -

- The API key provided with this cal is invalid. - Please create a valid Personal API key and use that with your request. -

- """, - }) + if not key: + return err(400, "Invalid apikey") + # Check endpoint + urlpath = request.META.get('PATH_INFO') + if not (urlpath and urlpath == key.endpoint): + return err(400, "Apikey endpoint mismatch") + # Check time since regular login + person = key.person + last_login = person.user.last_login + time_limit = (datetime.datetime.now() - datetime.timedelta(days=settings.UTILS_APIKEY_GUI_LOGIN_LIMIT_DAYS)) + if last_login < time_limit: + return err(400, "Too long since last regular login") + # Log in + login(request, person.user) + # restore the user.last_login field, so it reflects only gui logins + person.user.last_login = last_login + person.user.save() + # Update stats + key.count += 1 + key.latest = datetime.datetime.now() + key.save() + PersonApiKeyEvent.objects.create(person=person, type='apikey_login', key=key, desc="Logged in with key ID %s, endpoint %s" % (key.id, key.endpoint)) + # Execute decorated function + return f(request, *args, **kwargs)