Skip to content

Commit 152261a

Browse files
committed
Added new infrastructure for personal API keys, to generate, view, and delete them.
- Legacy-Id: 14423
1 parent 85a1007 commit 152261a

12 files changed

Lines changed: 342 additions & 23 deletions

File tree

ietf/ietfauth/tests.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, unicontent
1717
from ietf.utils.test_data import make_test_data, make_review_data
1818
from ietf.utils.mail import outbox, empty_outbox
19-
from ietf.person.models import Person, Email
2019
from ietf.group.models import Group, Role, RoleName
2120
from ietf.ietfauth.htpasswd import update_htpasswd_file
2221
from ietf.mailinglists.models import Subscribed
22+
from ietf.person.models import Person, Email
23+
from ietf.person.factories import PersonFactory
2324
from ietf.review.models import ReviewWish, UnavailablePeriod
2425
from ietf.utils.decorators import skip_coverage
2526

@@ -495,3 +496,58 @@ def test_change_username(self):
495496
user = User.objects.get(username="othername@example.org")
496497
self.assertEqual(prev, user)
497498
self.assertTrue(user.check_password(u'password'))
499+
500+
def test_apikey(self):
501+
person = PersonFactory()
502+
503+
url = urlreverse('ietf.ietfauth.views.apikey_index')
504+
505+
# Check that the url is protected, then log in
506+
login_testing_unauthorized(self, person.user.username, url)
507+
508+
# Check api key list content
509+
r = self.client.get(url)
510+
self.assertContains(r, 'Personal API keys')
511+
self.assertContains(r, 'Get a new personal API key')
512+
513+
# Check the add key form content
514+
url = urlreverse('ietf.ietfauth.views.apikey_add')
515+
r = self.client.get(url)
516+
self.assertContains(r, 'Create a new personal API key')
517+
self.assertContains(r, 'Endpoint')
518+
519+
# Add 2 keys
520+
r = self.client.post(url, {'endpoint': '/api/submit'})
521+
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
522+
r = self.client.post(url, {'endpoint': '/api/iesg/discuss'})
523+
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
524+
525+
# Check api key list content
526+
url = urlreverse('ietf.ietfauth.views.apikey_index')
527+
r = self.client.get(url)
528+
self.assertContains(r, '/api/submit')
529+
self.assertContains(r, '/api/iesg/discuss')
530+
q = PyQuery(r.content)
531+
self.assertEqual(len(q('td code')), 2)
532+
533+
# Get one of the keys
534+
key = person.apikeys.first()
535+
536+
# Check the delete key form content
537+
url = urlreverse('ietf.ietfauth.views.apikey_del')
538+
r = self.client.get(url)
539+
540+
self.assertContains(r, 'Delete a personal API key')
541+
self.assertContains(r, 'Key')
542+
543+
# Delete a key
544+
r = self.client.post(url, {'hash': key.hash()})
545+
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
546+
547+
# Check the api key list content again
548+
url = urlreverse('ietf.ietfauth.views.apikey_index')
549+
r = self.client.get(url)
550+
self.assertNotContains(r, key.endpoint)
551+
q = PyQuery(r.content)
552+
self.assertEqual(len(q('td code')), 1)
553+

ietf/ietfauth/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
urlpatterns = [
99
url(r'^$', views.index),
10+
url(r'^apikey/?$', views.apikey_index),
11+
url(r'^apikey/add/?$', views.apikey_add),
12+
url(r'^apikey/del/?$', views.apikey_del),
1013
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', views.confirm_new_email),
1114
url(r'^create/$', views.create_account),
1215
url(r'^create/confirm/(?P<auth>[^/]+)/$', views.confirm_account),

ietf/ietfauth/views.py

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
from django.contrib.auth.models import User
4949
from django.contrib.auth.views import login as django_login
5050
from django.contrib.sites.models import Site
51+
from django.core.validators import ValidationError
5152
from django.urls import reverse as urlreverse
5253
from django.http import Http404, HttpResponseRedirect #, HttpResponse,
5354
from django.shortcuts import render, redirect, get_object_or_404
@@ -61,11 +62,12 @@
6162
from ietf.ietfauth.htpasswd import update_htpasswd_file
6263
from ietf.ietfauth.utils import role_required
6364
from ietf.mailinglists.models import Subscribed, Whitelisted
64-
from ietf.person.models import Person, Email, Alias
65+
from ietf.person.models import Person, Email, Alias, PersonalApiKey
6566
from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewWish
6667
from ietf.review.utils import unavailable_periods_to_list, get_default_filter_re
67-
from ietf.utils.mail import send_mail
6868
from ietf.doc.fields import SearchableDocumentField
69+
from ietf.utils.decorators import person_required
70+
from ietf.utils.mail import send_mail
6971

7072
def index(request):
7173
return render(request, 'registration/index.html')
@@ -190,14 +192,10 @@ def confirm_account(request, auth):
190192
})
191193

192194
@login_required
195+
@person_required
193196
def profile(request):
194197
roles = []
195-
person = None
196-
197-
try:
198-
person = request.user.person
199-
except Person.DoesNotExist:
200-
return render(request, 'registration/missing_person.html')
198+
person = request.user.person
201199

202200
roles = Role.objects.filter(person=person, group__state='active').order_by('name__name', 'group__name')
203201
emails = Email.objects.filter(person=person).order_by('-active','-time')
@@ -533,13 +531,9 @@ def change_password(request):
533531

534532

535533
@login_required
534+
@person_required
536535
def change_username(request):
537-
person = None
538-
539-
try:
540-
person = request.user.person
541-
except Person.DoesNotExist:
542-
return render(request, 'registration/missing_person.html')
536+
person = request.user.person
543537

544538
emails = [ e.address for e in Email.objects.filter(person=person, active=True) ]
545539
emailz = [ e.address for e in person.email_set.filter(active=True) ]
@@ -599,3 +593,60 @@ def login(request, extra_context=None):
599593
}
600594

601595
return django_login(request, extra_context=extra_context)
596+
597+
@login_required
598+
@person_required
599+
def apikey_index(request):
600+
person = request.user.person
601+
return render(request, 'ietfauth/apikeys.html', {'person': person})
602+
603+
@login_required
604+
@person_required
605+
def apikey_add(request):
606+
class ApiKeyForm(forms.ModelForm):
607+
class Meta:
608+
model = PersonalApiKey
609+
fields = ['endpoint']
610+
#
611+
person = request.user.person
612+
if request.method == 'POST':
613+
form = ApiKeyForm(request.POST)
614+
if form.is_valid():
615+
api_key = form.save(commit=False)
616+
api_key.person = person
617+
api_key.save()
618+
return redirect('ietf.ietfauth.views.apikey_index')
619+
else:
620+
form = ApiKeyForm()
621+
return render(request, 'form.html', {'form':form, 'title':"Create a new personal API key", 'description':'', 'button':'Create key'})
622+
623+
624+
@login_required
625+
@person_required
626+
def apikey_del(request):
627+
person = request.user.person
628+
choices = [ (k.hash(), str(k)) for k in person.apikeys.all() ]
629+
#
630+
class KeyDeleteForm(forms.Form):
631+
hash = forms.ChoiceField(label='Key', choices=choices)
632+
def clean_key(self):
633+
hash = self.cleaned_data['hash']
634+
key = PersonalApiKey.validate_key(hash)
635+
if key and key.person == request.user.person:
636+
return hash
637+
else:
638+
raise ValidationError("Bad key value")
639+
#
640+
if request.method == 'POST':
641+
form = KeyDeleteForm(request.POST)
642+
if form.is_valid():
643+
hash = form.data['hash']
644+
key = PersonalApiKey.validate_key(hash)
645+
key.delete()
646+
messages.success(request, "Deleted key %s" % hash)
647+
return redirect('ietf.ietfauth.views.apikey_index')
648+
else:
649+
messages.error(request, "Key validation failed; key not deleted")
650+
else:
651+
form = KeyDeleteForm(request.GET)
652+
return render(request, 'form.html', {'form':form, 'title':"Delete a personal API key", 'description':'', 'button':'Delete key'})

ietf/person/admin.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.contrib import admin
22

33

4-
from ietf.person.models import Email, Alias, Person, PersonHistory
4+
from ietf.person.models import Email, Alias, Person, PersonHistory, PersonalApiKey
55
from ietf.person.name import name_parts
66

77
class EmailAdmin(admin.ModelAdmin):
@@ -43,4 +43,9 @@ def plain_name(self, obj):
4343
search_fields = ['name', 'ascii']
4444
admin.site.register(PersonHistory, PersonHistoryAdmin)
4545

46-
46+
class PersonalApiKeyAdmin(admin.ModelAdmin):
47+
list_display = ['id', 'person', 'created', 'endpoint', 'valid', 'count', 'latest', ]
48+
list_filter = ['endpoint', 'created', ]
49+
raw_id_fields = ['person', ]
50+
search_fields = ['person__name', ]
51+
admin.site.register(PersonalApiKey, PersonalApiKeyAdmin)

ietf/person/models.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import datetime
44
import email.utils
55
import email.header
6+
import six
7+
import uuid
8+
69
from hashids import Hashids
710
from urlparse import urljoin
811

@@ -274,3 +277,48 @@ def email_address(self):
274277
return
275278
return self.address
276279

280+
281+
# "{key.id}{salt}{hash}
282+
KEY_STRUCT = "i12s32s"
283+
284+
def salt():
285+
return uuid.uuid4().bytes[:12]
286+
287+
API_KEY_ENDPOINTS = [
288+
("/api/submit", "/api/submit"),
289+
("/api/iesg/discuss", "/api/iesg/discuss"),
290+
]
291+
292+
class PersonalApiKey(models.Model):
293+
person = models.ForeignKey(Person, related_name='apikeys')
294+
endpoint = models.CharField(max_length=128, null=False, blank=False, choices=API_KEY_ENDPOINTS)
295+
created = models.DateTimeField(default=datetime.datetime.now, null=False)
296+
valid = models.BooleanField(default=True)
297+
salt = models.BinaryField(default=salt, max_length=12, null=False, blank=False)
298+
count = models.IntegerField(default=0, null=False, blank=False)
299+
latest = models.DateTimeField(blank=True, null=True)
300+
301+
@classmethod
302+
def validate_key(cls, s):
303+
import struct, hashlib, base64
304+
key = base64.urlsafe_b64decode(six.binary_type(s))
305+
id, salt, hash = struct.unpack(KEY_STRUCT, key)
306+
k = cls.objects.get(id=id)
307+
check = hashlib.sha256()
308+
for v in (str(id), str(k.person.id), k.created.isoformat(), k.endpoint, str(k.valid), salt, settings.SECRET_KEY):
309+
check.update(v)
310+
return k if check.digest() == hash else None
311+
312+
def hash(self):
313+
import struct, hashlib, base64
314+
if not hasattr(self, '_cached_hash'):
315+
hash = hashlib.sha256()
316+
# Hash over: ( id, person, created, endpoint, valid, salt, secret )
317+
for v in (str(self.id), str(self.person.id), self.created.isoformat(), self.endpoint, str(self.valid), self.salt, settings.SECRET_KEY):
318+
hash.update(v)
319+
key = struct.pack(KEY_STRUCT, self.id, six.binary_type(self.salt), hash.digest())
320+
self._cached_hash = base64.urlsafe_b64encode(key)
321+
return self._cached_hash
322+
323+
def __unicode__(self):
324+
return "%s (%s): %s ..." % (self.endpoint, self.created.strftime("%Y-%m-%d %H:%M"), self.hash()[:16])

ietf/person/resources.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from ietf import api
88

9-
from ietf.person.models import (Person, Email, Alias, PersonHistory)
9+
from ietf.person.models import (Person, Email, Alias, PersonHistory, PersonalApiKey)
1010

1111

1212
from ietf.utils.resources import UserResource
@@ -82,3 +82,23 @@ class Meta:
8282
"user": ALL_WITH_RELATIONS,
8383
}
8484
api.person.register(PersonHistoryResource())
85+
86+
87+
class PersonalApiKeyResource(ModelResource):
88+
person = ToOneField(PersonResource, 'person')
89+
class Meta:
90+
queryset = PersonalApiKey.objects.all()
91+
serializer = api.Serializer()
92+
cache = SimpleCache()
93+
#resource_name = 'personalapikey'
94+
filtering = {
95+
"id": ALL,
96+
"endpoint": ALL,
97+
"created": ALL,
98+
"valid": ALL,
99+
"salt": ALL,
100+
"count": ALL,
101+
"latest": ALL,
102+
"person": ALL_WITH_RELATIONS,
103+
}
104+
api.person.register(PersonalApiKeyResource())

ietf/templates/base.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@
108108
<![endif]-->
109109

110110
{% endif %}
111-
{% block content %}{% endblock %}
111+
{% block content %}{{ content|safe }}{% endblock %}
112112
{% block content_end %}{% endblock %}
113113
{% if request.COOKIES.left_menu != "off" and not hide_menu %}
114114
</div>

ietf/templates/base/menu_user.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<li><a rel="nofollow" href="/accounts/logout/" >Sign out</a></li>
1919
<li><a rel="nofollow" href="/accounts/profile/">Account info</a></li>
2020
<li><a href="{%url "ietf.cookies.views.preferences" %}" rel="nofollow">Preferences</a></li>
21+
<li><a href="{%url "ietf.ietfauth.views.apikey_index" %}" rel="nofollow">API keys</a></li>
2122
<li><a rel="nofollow" href="/accounts/password/">Change password</a></li>
2223
<li><a rel="nofollow" href="/accounts/username/">Change username</a></li>
2324
{% else %}

ietf/templates/form.html

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{% extends "base.html" %}
2+
{# Copyright The IETF Trust 2015, All Rights Reserved #}
3+
{% load origin %}
4+
{% load staticfiles %}
5+
{% load bootstrap3 %}
6+
7+
{% block title %}{{ title|striptags }}{% endblock %}
8+
9+
{% block pagehead %}
10+
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
11+
{% endblock %}
12+
13+
{% block content %}
14+
{% origin %}
15+
<h1>{{ title|safe }}</h1>
16+
17+
<p>
18+
{{ description|safe }}
19+
</p>
20+
21+
<form method="post" class="show-required">
22+
{% csrf_token %}
23+
24+
{% bootstrap_form form %}
25+
26+
{% buttons %}
27+
<button type="submit" name="{{button|slugify}}" class="btn btn-primary">{{ button }}</button>
28+
{% endbuttons %}
29+
</form>
30+
{% endblock %}
31+
32+
{% block js %}
33+
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
34+
{% endblock %}

0 commit comments

Comments
 (0)