Merged in ^/personal/henrik/6.64.2-ballotapi@14426. This provides personal API keys and a ballot position API at /api/iesg/position. Also added an endpoint description at /api/.

- Legacy-Id: 14430
This commit is contained in:
Henrik Levkowetz 2017-12-17 23:43:44 +00:00
commit 6567e707ce
27 changed files with 947 additions and 101 deletions

View file

@ -3,8 +3,7 @@
# Nightly datatracker jobs.
#
# This script is expected to be triggered by cron from
# $DTDIR/etc/cron.d/datatracker which should be symlinked from
# /etc/cron.d/
# /etc/cron.d/datatracker
# Run the hourly jobs first
$DTDIR/bin/hourly

View file

@ -3,8 +3,7 @@
# Hourly datatracker jobs
#
# This script is expected to be triggered by cron from
# $DTDIR/etc/cron.d/datatracker which should be symlinked from
# /etc/cron.d/
# /etc/cron.d/datatracker
DTDIR=/a/www/ietf-datatracker/web
cd $DTDIR/

15
bin/monthly Executable file
View file

@ -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"

20
bin/weekly Executable file
View file

@ -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

View file

@ -4,6 +4,7 @@ from django.conf.urls import include
from ietf import api
from ietf.api import views as api_views
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
@ -17,6 +18,7 @@ urlpatterns = [
# Custom API endpoints
url(r'^notify/meeting/import_recordings/(?P<number>[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

View file

@ -13,7 +13,7 @@ from ietf.doc.utils import create_ballot_if_not_open
from ietf.group.models import Group, Role
from ietf.name.models import BallotPositionName
from ietf.iesg.models import TelechatDate
from ietf.person.models import Person
from ietf.person.models import Person, PersonalApiKey
from ietf.utils.test_utils import TestCase, unicontent
from ietf.utils.mail import outbox, empty_outbox
from ietf.utils.test_data import make_test_data
@ -85,6 +85,67 @@ class EditPositionTests(TestCase):
self.assertEqual(draft.docevent_set.count(), events_before + 2)
self.assertTrue("Ballot comment text updated" in pos.desc)
def test_api_set_position(self):
draft = make_test_data()
url = urlreverse('ietf.doc.views_ballot.api_set_position')
ad = Person.objects.get(name="Areað Irector")
create_ballot_if_not_open(None, draft, ad, 'approve')
ad.user.last_login = datetime.datetime.now()
ad.user.save()
apikey = PersonalApiKey.objects.create(endpoint=url, person=ad)
# vote
events_before = draft.docevent_set.count()
r = self.client.post(url, dict(
apikey=apikey.hash(),
doc=draft.name,
position="discuss",
discuss=" This is a discussion test. \n ",
comment=" This is a test. \n ")
)
self.assertEqual(r.content, "Done")
self.assertEqual(r.status_code, 200)
pos = draft.latest_event(BallotPositionDocEvent, ad=ad)
self.assertEqual(pos.pos.slug, "discuss")
self.assertTrue(" This is a discussion test." in pos.discuss)
self.assertTrue(pos.discuss_time != None)
self.assertTrue(" This is a test." in pos.comment)
self.assertTrue(pos.comment_time != None)
self.assertTrue("New position" in pos.desc)
self.assertEqual(draft.docevent_set.count(), events_before + 3)
# recast vote
events_before = draft.docevent_set.count()
r = self.client.post(url, dict(apikey=apikey.hash(), doc=draft.name, position="noobj"))
self.assertEqual(r.status_code, 200)
pos = draft.latest_event(BallotPositionDocEvent, ad=ad)
self.assertEqual(pos.pos.slug, "noobj")
self.assertEqual(draft.docevent_set.count(), events_before + 1)
self.assertTrue("Position for" in pos.desc)
# clear vote
events_before = draft.docevent_set.count()
r = self.client.post(url, dict(apikey=apikey.hash(), doc=draft.name, position="norecord"))
self.assertEqual(r.status_code, 200)
pos = draft.latest_event(BallotPositionDocEvent, ad=ad)
self.assertEqual(pos.pos.slug, "norecord")
self.assertEqual(draft.docevent_set.count(), events_before + 1)
self.assertTrue("Position for" in pos.desc)
# change comment
events_before = draft.docevent_set.count()
r = self.client.post(url, dict(apikey=apikey.hash(), doc=draft.name, position="norecord", comment="New comment."))
self.assertEqual(r.status_code, 200)
pos = draft.latest_event(BallotPositionDocEvent, ad=ad)
self.assertEqual(pos.pos.slug, "norecord")
self.assertEqual(draft.docevent_set.count(), events_before + 2)
self.assertTrue("Ballot comment text updated" in pos.desc)
def test_edit_position_as_secretary(self):
draft = make_test_data()
ad = Person.objects.get(user__username="ad")

View file

@ -3,12 +3,15 @@
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.defaultfilters import striptags
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
@ -30,6 +33,7 @@ from ietf.name.models import BallotPositionName
from ietf.person.models import Person
from ietf.utils import log
from ietf.utils.mail import send_mail_text, send_mail_preformatted
from ietf.utils.decorators import require_api_key
BALLOT_CHOICES = (("yes", "Yes"),
("noobj", "No Objection"),
@ -106,38 +110,13 @@ class EditPositionForm(forms.Form):
raise forms.ValidationError("You must enter a non-empty discuss")
return entered_discuss
@role_required('Area Director','Secretariat')
def edit_position(request, name, ballot_id):
"""Vote and edit discuss and comment on document as Area Director."""
doc = get_object_or_404(Document, docalias__name=name)
ballot = get_object_or_404(BallotDocEvent, type="created_ballot", pk=ballot_id, doc=doc)
ad = login = request.user.person
if 'ballot_edit_return_point' in request.session:
return_to_url = request.session['ballot_edit_return_point']
else:
return_to_url = urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=doc.name, ballot_id=ballot_id))
# if we're in the Secretariat, we can select an AD to act as stand-in for
if has_role(request.user, "Secretariat"):
ad_id = request.GET.get('ad')
if not ad_id:
raise Http404
ad = get_object_or_404(Person, pk=ad_id)
old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", ad=ad, ballot=ballot)
if request.method == 'POST':
if not has_role(request.user, "Secretariat") and not ad.role_set.filter(name="ad", group__type="area", group__state="active"):
# prevent pre-ADs from voting
return HttpResponseForbidden("Must be a proper Area Director in an active area to cast ballot")
form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type)
if form.is_valid():
def save_position(form, doc, ballot, ad, login=None):
# save the vote
if login is None:
login = ad
clean = form.cleaned_data
old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", ad=ad, ballot=ballot)
pos = BallotPositionDocEvent(doc=doc, rev=doc.rev, by=login)
pos.type = "changed_ballot_position"
pos.ballot = ballot
@ -198,6 +177,36 @@ def edit_position(request, name, ballot_id):
for e in added_events:
e.save() # save them after the position is saved to get later id for sorting order
@role_required('Area Director','Secretariat')
def edit_position(request, name, ballot_id):
"""Vote and edit discuss and comment on document as Area Director."""
doc = get_object_or_404(Document, docalias__name=name)
ballot = get_object_or_404(BallotDocEvent, type="created_ballot", pk=ballot_id, doc=doc)
ad = login = request.user.person
if 'ballot_edit_return_point' in request.session:
return_to_url = request.session['ballot_edit_return_point']
else:
return_to_url = urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=doc.name, ballot_id=ballot_id))
# if we're in the Secretariat, we can select an AD to act as stand-in for
if has_role(request.user, "Secretariat"):
ad_id = request.GET.get('ad')
if not ad_id:
raise Http404
ad = get_object_or_404(Person, pk=ad_id)
if request.method == 'POST':
if not has_role(request.user, "Secretariat") and not ad.role_set.filter(name="ad", group__type="area", group__state="active"):
# prevent pre-ADs from voting
return HttpResponseForbidden("Must be a proper Area Director in an active area to cast ballot")
form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type)
if form.is_valid():
save_position(form, doc, ballot, ad, login)
if request.POST.get("send_mail"):
qstr=""
if request.GET.get('ad'):
@ -211,6 +220,7 @@ def edit_position(request, name, ballot_id):
return HttpResponseRedirect(return_to_url)
else:
initial = {}
old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", ad=ad, ballot=ballot)
if old_pos:
initial['position'] = old_pos.pos.slug
initial['discuss'] = old_pos.discuss
@ -234,6 +244,45 @@ def edit_position(request, name, ballot_id):
blocking_positions=json.dumps(blocking_positions),
))
@require_api_key
@role_required('Area Director', 'Secretariat')
@csrf_exempt
def api_set_position(request):
def err(code, text):
return HttpResponse(text, status=code, content_type='text/plain')
if request.method == 'POST':
ad = request.user.person
name = request.POST.get('doc')
if not name:
return err(400, "Missing document name")
try:
doc = Document.objects.get(docalias__name=name)
except Document.DoesNotExist:
return err(404, "Document not found")
position_names = BallotPositionName.objects.values_list('slug', flat=True)
position = request.POST.get('position')
if not position:
return err(400, "Missing parameter: position, one of: %s " % ','.join(position_names))
if not position in position_names:
return err(400, "Bad position name, must be one of: %s " % ','.join(position_names))
ballot = doc.active_ballot()
if not ballot:
return err(404, "No open ballot found")
form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type)
if form.is_valid():
save_position(form, doc, ballot, ad)
else:
debug.type('form.errors')
debug.show('form.errors')
errors = form.errors
summary = ','.join([ "%s: %s" % (f, striptags(errors[f])) for f in errors ])
return err(400, "Form not valid: %s" % summary)
else:
return err(405, "Method not allowed")
return HttpResponse("Done", status=200, content_type='text/plain')
@role_required('Area Director','Secretariat')
def send_ballot_comment(request, name, ballot_id):
"""Email document ballot position discuss/comment for Area Director."""

View file

View file

@ -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, } )

View file

@ -1,5 +1,6 @@
# Copyright The IETF Trust 2017, All Rights Reserved
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from __future__ import unicode_literals, print_function
import os, shutil, time, datetime
from urlparse import urlsplit
@ -16,10 +17,12 @@ import debug # pyflakes:ignore
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, unicontent
from ietf.utils.test_data import make_test_data, make_review_data
from ietf.utils.mail import outbox, empty_outbox
from ietf.person.models import Person, Email
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, 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
@ -99,7 +102,7 @@ class IetfAuthTests(TestCase):
if l.startswith(username + ":"):
return True
with open(settings.HTPASSWD_FILE) as f:
print f.read()
print(f.read())
return False
@ -495,3 +498,145 @@ class IetfAuthTests(TestCase):
user = User.objects.get(username="othername@example.org")
self.assertEqual(prev, user)
self.assertTrue(user.check_password(u'password'))
def test_apikey_management(self):
person = PersonFactory()
url = urlreverse('ietf.ietfauth.views.apikey_index')
# Check that the url is protected, then log in
login_testing_unauthorized(self, person.user.username, url)
# Check api key list content
r = self.client.get(url)
self.assertContains(r, 'Personal API keys')
self.assertContains(r, 'Get a new personal API key')
# Check the add key form content
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
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)
for endpoint, display in PERSON_API_KEY_ENDPOINTS:
self.assertContains(r, endpoint)
q = PyQuery(r.content)
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 disable key form content
url = urlreverse('ietf.ietfauth.views.apikey_disable')
r = self.client.get(url)
self.assertContains(r, 'Disable a personal API key')
self.assertContains(r, 'Key')
# Delete a key
r = self.client.post(url, {'hash': key.hash()})
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
# Check the api key list content again
url = urlreverse('ietf.ietfauth.views.apikey_index')
r = self.client.get(url)
q = PyQuery(r.content)
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_errors(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
# 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(decode=True).decode('utf-8')
self.assertIn("API key usage", mail['subject'])
self.assertIn(" %s times" % count, body)
self.assertIn(date, body)

View file

@ -7,6 +7,9 @@ from ietf.utils.urls import url
urlpatterns = [
url(r'^$', views.index),
url(r'^apikey/?$', views.apikey_index),
url(r'^apikey/add/?$', views.apikey_create),
url(r'^apikey/del/?$', views.apikey_disable),
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', views.confirm_new_email),
url(r'^create/$', views.create_account),
url(r'^create/confirm/(?P<auth>[^/]+)/$', views.confirm_account),

View file

@ -48,6 +48,7 @@ from django.contrib.auth.hashers import identify_hasher
from django.contrib.auth.models import User
from django.contrib.auth.views import login as django_login
from django.contrib.sites.models import Site
from django.core.validators import ValidationError
from django.urls import reverse as urlreverse
from django.http import Http404, HttpResponseRedirect #, HttpResponse,
from django.shortcuts import render, redirect, get_object_or_404
@ -61,11 +62,12 @@ from ietf.ietfauth.forms import ( RegistrationForm, PasswordForm, ResetPasswordF
from ietf.ietfauth.htpasswd import update_htpasswd_file
from ietf.ietfauth.utils import role_required
from ietf.mailinglists.models import Subscribed, Whitelisted
from ietf.person.models import Person, Email, Alias
from ietf.person.models import Person, Email, Alias, PersonalApiKey
from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewWish
from ietf.review.utils import unavailable_periods_to_list, get_default_filter_re
from ietf.utils.mail import send_mail
from ietf.doc.fields import SearchableDocumentField
from ietf.utils.decorators import person_required
from ietf.utils.mail import send_mail
def index(request):
return render(request, 'registration/index.html')
@ -190,14 +192,10 @@ def confirm_account(request, auth):
})
@login_required
@person_required
def profile(request):
roles = []
person = None
try:
person = request.user.person
except Person.DoesNotExist:
return render(request, 'registration/missing_person.html')
roles = Role.objects.filter(person=person, group__state='active').order_by('name__name', 'group__name')
emails = Email.objects.filter(person=person).order_by('-active','-time')
@ -533,13 +531,9 @@ def change_password(request):
@login_required
@person_required
def change_username(request):
person = None
try:
person = request.user.person
except Person.DoesNotExist:
return render(request, 'registration/missing_person.html')
emails = [ e.address for e in Email.objects.filter(person=person, active=True) ]
emailz = [ e.address for e in person.email_set.filter(active=True) ]
@ -599,3 +593,61 @@ def login(request, extra_context=None):
}
return django_login(request, extra_context=extra_context)
@login_required
@person_required
def apikey_index(request):
person = request.user.person
return render(request, 'ietfauth/apikeys.html', {'person': person})
@login_required
@person_required
def apikey_create(request):
class ApiKeyForm(forms.ModelForm):
class Meta:
model = PersonalApiKey
fields = ['endpoint']
#
person = request.user.person
if request.method == 'POST':
form = ApiKeyForm(request.POST)
if form.is_valid():
api_key = form.save(commit=False)
api_key.person = person
api_key.save()
return redirect('ietf.ietfauth.views.apikey_index')
else:
form = ApiKeyForm()
return render(request, 'form.html', {'form':form, 'title':"Create a new personal API key", 'description':'', 'button':'Create key'})
@login_required
@person_required
def apikey_disable(request):
person = request.user.person
choices = [ (k.hash(), str(k)) for k in person.apikeys.all() ]
#
class KeyDeleteForm(forms.Form):
hash = forms.ChoiceField(label='Key', choices=choices)
def clean_key(self):
hash = self.cleaned_data['hash']
key = PersonalApiKey.validate_key(hash)
if key and key.person == request.user.person:
return hash
else:
raise ValidationError("Bad key value")
#
if request.method == 'POST':
form = KeyDeleteForm(request.POST)
if form.is_valid():
hash = form.data['hash']
key = PersonalApiKey.validate_key(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 disabled")
else:
form = KeyDeleteForm(request.GET)
return render(request, 'form.html', {'form':form, 'title':"Disable a personal API key", 'description':'', 'button':'Disable key'})

View file

@ -1,7 +1,7 @@
from django.contrib import admin
from ietf.person.models import Email, Alias, Person, PersonHistory
from ietf.person.models import Email, Alias, Person, PersonHistory, PersonalApiKey, PersonEvent, PersonApiKeyEvent
from ietf.person.name import name_parts
class EmailAdmin(admin.ModelAdmin):
@ -43,4 +43,21 @@ class PersonHistoryAdmin(admin.ModelAdmin):
search_fields = ['name', 'ascii']
admin.site.register(PersonHistory, PersonHistoryAdmin)
class PersonalApiKeyAdmin(admin.ModelAdmin):
list_display = ['id', 'person', 'created', 'endpoint', 'valid', 'count', 'latest', ]
list_filter = ['endpoint', 'created', ]
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)

View file

@ -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'),
),
]

View file

@ -3,6 +3,9 @@
import datetime
import email.utils
import email.header
import six
import uuid
from hashids import Hashids
from urlparse import urljoin
@ -274,3 +277,71 @@ class Email(models.Model):
return
return self.address
# "{key.id}{salt}{hash}
KEY_STRUCT = "i12s32s"
def salt():
return uuid.uuid4().bytes[:12]
# Manual maintenance: List all endpoints that use @require_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=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)
count = models.IntegerField(default=0, null=False, blank=False)
latest = models.DateTimeField(blank=True, null=True)
@classmethod
def validate_key(cls, s):
import struct, hashlib, base64
key = base64.urlsafe_b64decode(six.binary_type(s))
id, salt, hash = struct.unpack(KEY_STRUCT, key)
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)
return k if check.digest() == hash else None
def hash(self):
import struct, hashlib, base64
if not hasattr(self, '_cached_hash'):
hash = hashlib.sha256()
# Hash over: ( id, person, created, endpoint, valid, salt, secret )
for v in (str(self.id), str(self.person.id), self.created.isoformat(), self.endpoint, str(self.valid), self.salt, settings.SECRET_KEY):
hash.update(v)
key = struct.pack(KEY_STRUCT, self.id, six.binary_type(self.salt), hash.digest())
self._cached_hash = base64.urlsafe_b64encode(key)
return self._cached_hash
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)

View file

@ -6,7 +6,7 @@ from tastypie.cache import SimpleCache
from ietf import api
from ietf.person.models import (Person, Email, Alias, PersonHistory)
from ietf.person.models import (Person, Email, Alias, PersonHistory, PersonalApiKey, PersonEvent, PersonApiKeyEvent)
from ietf.utils.resources import UserResource
@ -82,3 +82,61 @@ class PersonHistoryResource(ModelResource):
"user": ALL_WITH_RELATIONS,
}
api.person.register(PersonHistoryResource())
class PersonalApiKeyResource(ModelResource):
person = ToOneField(PersonResource, 'person')
class Meta:
queryset = PersonalApiKey.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'personalapikey'
filtering = {
"id": ALL,
"endpoint": ALL,
"created": ALL,
"valid": ALL,
"salt": ALL,
"count": ALL,
"latest": ALL,
"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())

View file

@ -932,6 +932,7 @@ SILENCED_SYSTEM_CHECKS = [
STATS_NAMES_LIMIT = 25
UTILS_TEST_RANDOM_STATE_FILE = '.factoryboy_random_state'
UTILS_APIKEY_GUI_LOGIN_LIMIT_DAYS = 30
API_PUBLIC_KEY_PEM = """
-----BEGIN PUBLIC KEY-----

View file

@ -8,6 +8,11 @@
<div class="col-md-1"></div>
<div class="col-md-11 bio-text">
<h3>Framework</h3>
<p>
This section describes the autogenrated read-only API towards the database tables. See also
the <a href="{% url 'ietf.submit.views.api_submit' %}">draft submission API description</a> and the
<a href="#iesg-position-api">IESG ballot position API description</a>
</p>
<p>
The datatracker API uses <a href="https://django-tastypie.readthedocs.org/">tastypie</a>
to generate an API which mirrors the Django ORM (Object Relational Mapping)
@ -165,8 +170,8 @@
The datatracker does not use any form of API keys currently (03 Nov
2017), but may do so in the future. If so, personal API keys will be
available from your <a href={% url 'ietf.ietfauth.views.profile'
%}>Account Profile</a> page when you are logged in, and document keys
available from your <a href="{% url 'ietf.ietfauth.views.apikey_index'%}">
Account API Key</a> page when you are logged in, and document keys
will be visible to document authors on the document status page when
logged in.
@ -186,8 +191,38 @@
<pre>{{key.export_to_pem}}</pre>
</div>
<h3 id="iesg-position-api">IESG ballot position API</h3>
<p>
A simplified IESG ballot position interface, intended for automation,
is available at <code>{% url 'ietf.doc.views_ballot.api_set_position' %}</code>.
</p>
<p>
The interface requires the use of a personal API key, which can be created at
<a href="{% url 'ietf.ietfauth.views.apikey_index' %}">{% url 'ietf.ietfauth.views.apikey_index' %}</a>
</p>
<p>
It takes the following parameters:
</p>
<ul>
<li><code>apikey</code> (required) which is the personal API key hash</li>
<li><code>doc</code> (required) which is the balloted document name</li>
<li><code>position</code> (required) which is the position slug, one of: yes, noobj, block, discuss, abstain, recuse, norecord </li>
<li><code>discuss</code> (required if position is 'discuss') which is the discuss text</li>
<li><code>comment</code> (optional) which is comment text</li>
</ul>
<p>
It returns an appropriate http result code, and a brief explanatory text message.
</p>
<p>
Here is an example:</li>
</p>
<pre>
$ curl -S -F "apikey=AwAAABVR3D5GHkVMhspKSxBCVknGMmqikNIhT85kSnghjaV_pYy26WV92mm-jpdi" -F "doc=draft-ietf-lamps-eai-addresses" -F "position=noobj" -F "comment=Comment text" https://datatracker.ietf.org/api/iesg/position
Done
</pre>
</div>
{% endblock %}

View file

@ -108,7 +108,7 @@
<![endif]-->
{% endif %}
{% block content %}{% endblock %}
{% block content %}{{ content|safe }}{% endblock %}
{% block content_end %}{% endblock %}
{% if request.COOKIES.left_menu != "off" and not hide_menu %}
</div>

View file

@ -18,6 +18,7 @@
<li><a rel="nofollow" href="/accounts/logout/" >Sign out</a></li>
<li><a rel="nofollow" href="/accounts/profile/">Account info</a></li>
<li><a href="{%url "ietf.cookies.views.preferences" %}" rel="nofollow">Preferences</a></li>
<li><a href="{%url "ietf.ietfauth.views.apikey_index" %}" rel="nofollow">API keys</a></li>
<li><a rel="nofollow" href="/accounts/password/">Change password</a></li>
<li><a rel="nofollow" href="/accounts/username/">Change username</a></li>
{% else %}

34
ietf/templates/form.html Normal file
View file

@ -0,0 +1,34 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% load staticfiles %}
{% load bootstrap3 %}
{% block title %}{{ title|striptags }}{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
{% endblock %}
{% block content %}
{% origin %}
<h1>{{ title|safe }}</h1>
<p>
{{ description|safe }}
</p>
<form method="post" class="show-required">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<button type="submit" name="{{button|slugify}}" class="btn btn-primary">{{ button }}</button>
{% endbuttons %}
</form>
{% endblock %}
{% block js %}
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
{% endblock %}

View file

@ -0,0 +1,51 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% load widget_tweaks bootstrap3 %}
{% load person_filters %}
{% block title %}API keys for {{ user }}{% endblock %}
{% block content %}
{% origin %}
<h1>API keys for {{ user.username }}</h1>
{% csrf_token %}
<div class="form-group">
<label class="col-sm-2 control-label">Personal API keys</label>
<div class="col-sm-10">
<div>
<table class="table table-condensed">
{% for key in person.apikeys.all %}
{% if forloop.first %}
<tr ><th>Endpoint</th><th>Created</th><th>Latest use</th><th>Count</th><th>Valid</th></tr>
{% endif %}
<tr>
<td>{{ key.endpoint }} </td>
<td>{{ key.created }} </td>
<td>{{ key.latest }} </td>
<td>{{ key.count }} </td>
<td>{{ key.valid }} </td>
</tr>
<tr>
<td style="border-top: 0" colspan="4"><code>{{ key.hash }}</code></td>
<td style="border-top: 0">
{% if key.valid %}
<a href="{%url 'ietf.ietfauth.views.apikey_disable' %}?hash={{key.hash}}" class="btn btn-warning btn-xs del-apikey">Disable</a>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td>You have no personal API keys.</td></tr>
<tr><td></td></tr>
{% endfor %}
</table>
<a href="{% url 'ietf.ietfauth.views.apikey_create' %}" class="btn btn-default btn-sm add-apikey">Get a new personal API key</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -34,8 +34,8 @@
</div>
</div>
<div class="col-md-12">
{% if person.role_set.exists %}
<div class="col-md-12">
<h2 id="roles">Roles</h2>
<table class="table">
{% for role in person.role_set.all|active_roles %}
@ -54,8 +54,10 @@
{{ person.first_name }} has no active roles as of {{ today }}.
{% endfor %}
</table>
{% endif %}
</div>
{% endif %}
<div class="col-md-6">
<h2 id="rfcs">RFCs</h2>

View file

@ -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 %}

View file

@ -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):
@ -15,3 +23,54 @@ def skip_coverage(f, *args, **kwargs):
return result
else:
return f(*args, **kwargs)
@decorator
def person_required(f, request, *args, **kwargs):
if not request.user.is_authenticated:
raise ValueError("The @person_required decorator should be called after @login_required.")
try:
request.user.person
except Person.DoesNotExist:
return render(request, 'registration/missing_person.html')
return f(request, *args, **kwargs)
@decorator
def require_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.get('apikey')
elif request.method == 'GET':
hash = request.GET.get('apikey')
else:
return err(405, "Method not allowed")
if not hash:
return err(400, "Missing apikey parameter")
# Check hash
key = PersonalApiKey.validate_key(hash)
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 == None or 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)

View file

@ -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, } )