Added new infrastructure for personal API keys, to generate, view, and delete them.
- Legacy-Id: 14423
This commit is contained in:
parent
85a1007922
commit
152261a869
ietf
ietfauth
person
templates
utils
|
@ -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)
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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'})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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 %}
|
47
ietf/templates/ietfauth/apikeys.html
Normal file
47
ietf/templates/ietfauth/apikeys.html
Normal 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> </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 %}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
""",
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue