Added new infrastructure for personal API keys, to generate, view, and delete them.

- Legacy-Id: 14423
This commit is contained in:
Henrik Levkowetz 2017-12-14 14:30:59 +00:00
parent 85a1007922
commit 152261a869
12 changed files with 342 additions and 23 deletions

View file

@ -16,10 +16,11 @@ 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.ietfauth.htpasswd import update_htpasswd_file
from ietf.mailinglists.models import Subscribed
from ietf.person.models import Person, Email
from ietf.person.factories import PersonFactory
from ietf.review.models import ReviewWish, UnavailablePeriod
from ietf.utils.decorators import skip_coverage
@ -495,3 +496,58 @@ 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(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_add')
r = self.client.get(url)
self.assertContains(r, 'Create a new personal API key')
self.assertContains(r, 'Endpoint')
# Add 2 keys
r = self.client.post(url, {'endpoint': '/api/submit'})
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
r = self.client.post(url, {'endpoint': '/api/iesg/discuss'})
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
# Check api key list content
url = urlreverse('ietf.ietfauth.views.apikey_index')
r = self.client.get(url)
self.assertContains(r, '/api/submit')
self.assertContains(r, '/api/iesg/discuss')
q = PyQuery(r.content)
self.assertEqual(len(q('td code')), 2)
# Get one of the keys
key = person.apikeys.first()
# Check the delete key form content
url = urlreverse('ietf.ietfauth.views.apikey_del')
r = self.client.get(url)
self.assertContains(r, 'Delete 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)
self.assertNotContains(r, key.endpoint)
q = PyQuery(r.content)
self.assertEqual(len(q('td code')), 1)

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_add),
url(r'^apikey/del/?$', views.apikey_del),
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')
person = request.user.person
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')
person = request.user.person
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,60 @@ 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_add(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_del(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.delete()
messages.success(request, "Deleted key %s" % hash)
return redirect('ietf.ietfauth.views.apikey_index')
else:
messages.error(request, "Key validation failed; key not deleted")
else:
form = KeyDeleteForm(request.GET)
return render(request, 'form.html', {'form':form, 'title':"Delete a personal API key", 'description':'', 'button':'Delete 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
from ietf.person.name import name_parts
class EmailAdmin(admin.ModelAdmin):
@ -43,4 +43,9 @@ 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)

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,48 @@ class Email(models.Model):
return
return self.address
# "{key.id}{salt}{hash}
KEY_STRUCT = "i12s32s"
def salt():
return uuid.uuid4().bytes[:12]
API_KEY_ENDPOINTS = [
("/api/submit", "/api/submit"),
("/api/iesg/discuss", "/api/iesg/discuss"),
]
class PersonalApiKey(models.Model):
person = models.ForeignKey(Person, related_name='apikeys')
endpoint = models.CharField(max_length=128, null=False, blank=False, choices=API_KEY_ENDPOINTS)
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.get(id=id)
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])

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)
from ietf.utils.resources import UserResource
@ -82,3 +82,23 @@ 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())

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,47 @@
{% 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>Key</th><th>Created</th><th>Endpoint</th><th>Valid</th><th>&nbsp;</th></tr>
{% endif %}
<tr>
<td><code>{{ key.hash }}</code></td>
<td>{{ key.created }} </td>
<td>{{ key.endpoint }} </td>
<td>{{ key.valid }} </td>
<td>
{% if key.valid %}
<a href="{%url 'ietf.ietfauth.views.apikey_del' %}?hash={{key.hash}}" class="btn btn-warning btn-xs del-apikey">Delete</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_add' %}" 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 %}
{% 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>
</div>
{% endif %}
<div class="col-md-6">
<h2 id="rfcs">RFCs</h2>

View file

@ -15,3 +15,55 @@ def skip_coverage(f, *args, **kwargs):
return result
else:
return f(*args, **kwargs)
@decorator
def person_required(f, request, *args, **kwargs):
from ietf.person.models import Person
from django.shortcuts import render
if not request.user.is_authenticated:
raise ValueError("The @person_required decorator should be called after @login_required.")
try:
request.user.person
except Person.DoesNotExist:
return render(request, 'registration/missing_person.html')
return f(request, *args, **kwargs)
@decorator
def verify_user_api_key(f, request, *args, **kwargs):
from ietf.person.models import Person, PersonalApiKey
from django.shortcuts import render
if not request.user.is_authenticated:
raise ValueError("The @verify_user_api_key decorator should be called after @login_required.")
try:
person = request.user.person
except Person.DoesNotExist:
return render(request, 'registration/missing_person.html')
if request.method == 'POST':
hash = request.POST['apikey']
elif request.method == 'GET':
hash = request.GET['apikey']
else:
return render(request, 'base.html', {
'content': """
<h1>Missing API key</h1>
<p>
There is no apikey provided with this call.
Please create a valid Personal API key and use that with your request.
</p>
""",
})
key = PersonalApiKey.validate_key(hash)
if key and key.person == person:
return f(request, key, *args, **kwargs)
else:
return render(request, 'base.html', {
'content': """
<h1>Bad API key</h1>
<p>
The API key provided with this cal is invalid.
Please create a valid Personal API key and use that with your request.
</p>
""",
})