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:
commit
6567e707ce
|
@ -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
|
||||
|
|
|
@ -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
15
bin/monthly
Executable 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
20
bin/weekly
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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."""
|
||||
|
|
0
ietf/ietfauth/management/__init__.py
Normal file
0
ietf/ietfauth/management/__init__.py
Normal file
0
ietf/ietfauth/management/commands/__init__.py
Normal file
0
ietf/ietfauth/management/commands/__init__.py
Normal 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, } )
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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'})
|
||||
|
|
|
@ -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)
|
||||
|
|
56
ietf/person/migrations/0021_personalapikey.py
Normal file
56
ietf/person/migrations/0021_personalapikey.py
Normal 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'),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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-----
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
34
ietf/templates/form.html
Normal 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 %}
|
51
ietf/templates/ietfauth/apikeys.html
Normal file
51
ietf/templates/ietfauth/apikeys.html
Normal 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 %}
|
|
@ -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>
|
||||
|
|
14
ietf/templates/utils/apikey_usage_report.txt
Normal file
14
ietf/templates/utils/apikey_usage_report.txt
Normal 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 %}
|
|
@ -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)
|
||||
|
|
51
ietf/utils/management/commands/send_apikey_usage_emails.py
Normal file
51
ietf/utils/management/commands/send_apikey_usage_emails.py
Normal 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, } )
|
||||
|
Loading…
Reference in a new issue