restored lost code
This commit is contained in:
commit
fd78a86e20
28
README.rst
Normal file
28
README.rst
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
django-simplekeys
|
||||||
|
=================
|
||||||
|
|
||||||
|
django-simplekeys is a reusable Django app that provides a simple way to add
|
||||||
|
API keys to an existing Django project, regardless of API framework.
|
||||||
|
|
||||||
|
* GitHub: https://github.com/jamesturk/django-simplekeys
|
||||||
|
* Documentation: https://django-simplekeys.readthedocs.io/en/latest/
|
||||||
|
|
||||||
|
.. image:: https://travis-ci.org/jamesturk/django-simplekeys.svg?branch=master
|
||||||
|
:target: https://travis-ci.org/jamesturk/django-simplekeys
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/pypi/v/django-simplekeys.svg
|
||||||
|
:target: https://pypi.python.org/pypi/django-simplekeys
|
||||||
|
|
||||||
|
.. image:: https://readthedocs.org/projects/django-simplekeys/badge/?version=latest
|
||||||
|
:target: https://django-simplekeys.readthedocs.io/en/latest/
|
||||||
|
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
|
||||||
|
* `Token bucket <https://en.wikipedia.org/wiki/Token_bucket>`_ rate limiting, for limiting requests/second with optional bursting behavior.
|
||||||
|
* Quota-based rate limiting (e.g. requests/day)
|
||||||
|
* Ability to configure different usage tiers, to give different users different rates/quotas.
|
||||||
|
* Ability to configure different 'zones' so that different API methods can have different limits. (e.g. some particularly computationally expensive queries can have a much lower limit than cheap GET queries)
|
||||||
|
* Provided views for very simple email-based API key registration.
|
||||||
|
|
0
example/__init__.py
Normal file
0
example/__init__.py
Normal file
75
example/settings.py
Normal file
75
example/settings.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import os
|
||||||
|
import django
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
SECRET_KEY = 'not-a-secret'
|
||||||
|
DEBUG = True
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
|
SITE_ID = 1
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.sites',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'simplekeys',
|
||||||
|
]
|
||||||
|
|
||||||
|
SIMPLEKEYS_RATE_LIMIT_BACKEND = 'simplekeys.backends.MemoryBackend'
|
||||||
|
SIMPLEKEYS_ZONE_PATHS = [('/via.*/', 'default')]
|
||||||
|
|
||||||
|
_MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
'simplekeys.middleware.SimpleKeysMiddleware',
|
||||||
|
]
|
||||||
|
if django.VERSION >= (1, 11):
|
||||||
|
MIDDLEWARE = _MIDDLEWARE
|
||||||
|
else:
|
||||||
|
MIDDLEWARE_CLASSES = _MIDDLEWARE
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'simplekeys.tests.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'example.wsgi.application'
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_L10N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = '/static/'
|
16
example/wsgi.py
Normal file
16
example/wsgi.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for example project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings")
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
10
setup.cfg
Normal file
10
setup.cfg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[bdist_wheel]
|
||||||
|
universal = 1
|
||||||
|
|
||||||
|
[flake8]
|
||||||
|
exclude = simplekeys/migrations
|
||||||
|
|
||||||
|
[egg_info]
|
||||||
|
tag_build =
|
||||||
|
tag_date = 0
|
||||||
|
|
42
setup.py
Normal file
42
setup.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='django-simplekeys',
|
||||||
|
version="0.5.3",
|
||||||
|
packages=find_packages(),
|
||||||
|
include_package_data=True,
|
||||||
|
zip_safe=False,
|
||||||
|
description='Django API Key management & validation',
|
||||||
|
author='James Turk',
|
||||||
|
author_email='dev@jamesturk.net',
|
||||||
|
license='MIT License',
|
||||||
|
url='http://github.com/jamesturk/django-simplekeys/',
|
||||||
|
long_description=open('README.rst').read(),
|
||||||
|
platforms=["any"],
|
||||||
|
install_requires=[
|
||||||
|
"Django",
|
||||||
|
],
|
||||||
|
extras_require={
|
||||||
|
'dev': [
|
||||||
|
'freezegun',
|
||||||
|
'flake8',
|
||||||
|
'sphinx',
|
||||||
|
'sphinx-rtd-theme',
|
||||||
|
]
|
||||||
|
},
|
||||||
|
classifiers=[
|
||||||
|
'Development Status :: 4 - Beta',
|
||||||
|
'Intended Audience :: Developers',
|
||||||
|
'License :: OSI Approved :: MIT License',
|
||||||
|
'Natural Language :: English',
|
||||||
|
'Operating System :: OS Independent',
|
||||||
|
'Programming Language :: Python',
|
||||||
|
'Environment :: Web Environment',
|
||||||
|
'Programming Language :: Python :: 2',
|
||||||
|
'Programming Language :: Python :: 2.7',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
|
'Programming Language :: Python :: 3.5',
|
||||||
|
'Programming Language :: Python :: 3.6',
|
||||||
|
'Programming Language :: Python :: 3.7',
|
||||||
|
],
|
||||||
|
)
|
0
simplekeys/__init__.py
Normal file
0
simplekeys/__init__.py
Normal file
31
simplekeys/admin.py
Normal file
31
simplekeys/admin.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Tier, Zone, Limit, Key
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Key)
|
||||||
|
class KeyAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('key', 'email', 'name', 'tier', 'status', 'created_at')
|
||||||
|
list_filter = ('tier', 'status')
|
||||||
|
search_fields = ('email', 'name', 'key')
|
||||||
|
list_select_related = ('tier',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Zone)
|
||||||
|
class ZoneAdmin(admin.ModelAdmin):
|
||||||
|
fields = ('name', 'slug')
|
||||||
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
|
||||||
|
|
||||||
|
class LimitInline(admin.TabularInline):
|
||||||
|
model = Limit
|
||||||
|
extra = 1
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Tier)
|
||||||
|
class TierAdmin(admin.ModelAdmin):
|
||||||
|
fields = ('name', 'slug')
|
||||||
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
inlines = [
|
||||||
|
LimitInline,
|
||||||
|
]
|
5
simplekeys/apps.py
Normal file
5
simplekeys/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SimplekeysConfig(AppConfig):
|
||||||
|
name = 'simplekeys'
|
119
simplekeys/backends.py
Normal file
119
simplekeys/backends.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import time
|
||||||
|
import itertools
|
||||||
|
import datetime
|
||||||
|
from collections import Counter
|
||||||
|
from django.conf import settings
|
||||||
|
from .models import Zone, Key
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractBackend(object):
|
||||||
|
def get_tokens_and_timestamp(self, kz):
|
||||||
|
"""
|
||||||
|
returns token_count, timestamp for kz
|
||||||
|
|
||||||
|
if not found return (0, None)
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def set_token_count(self, kz, tokens):
|
||||||
|
"""
|
||||||
|
set counter for kz & timestamp to current time
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def get_and_inc_quota_value(self, key, zone, quota_range):
|
||||||
|
"""
|
||||||
|
increment & get quota value
|
||||||
|
(value will increase regardless of validity)
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def get_usage(self, keys=None, days=7):
|
||||||
|
"""
|
||||||
|
get usage in a nested dictionary
|
||||||
|
|
||||||
|
{
|
||||||
|
api_key: {
|
||||||
|
date: {
|
||||||
|
zone: N
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
such that result['apikey']['20170501']['default'] is equal to the
|
||||||
|
number of requests made by 'apikey' to 'default' zone endpoints on
|
||||||
|
May 1, 2017.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryBackend(AbstractBackend):
|
||||||
|
def __init__(self):
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self._counter = {}
|
||||||
|
self._last_replenished = {}
|
||||||
|
self._quota = Counter()
|
||||||
|
|
||||||
|
def get_tokens_and_timestamp(self, key, zone):
|
||||||
|
kz = (key, zone)
|
||||||
|
return self._counter.get(kz, 0), self._last_replenished.get(kz)
|
||||||
|
|
||||||
|
def set_token_count(self, key, zone, tokens):
|
||||||
|
kz = (key, zone)
|
||||||
|
self._last_replenished[kz] = time.time()
|
||||||
|
self._counter[kz] = tokens
|
||||||
|
|
||||||
|
def get_and_inc_quota_value(self, key, zone, quota_range):
|
||||||
|
quota_key = '{}-{}-{}'.format(key, zone, quota_range)
|
||||||
|
self._quota[quota_key] += 1
|
||||||
|
return self._quota[quota_key]
|
||||||
|
|
||||||
|
|
||||||
|
class CacheBackend(AbstractBackend):
|
||||||
|
def __init__(self):
|
||||||
|
from django.core.cache import caches
|
||||||
|
self.cache = caches[getattr(settings, 'SIMPLEKEYS_CACHE', 'default')]
|
||||||
|
# 25 hour default, just longer than a day so that day limits are OK
|
||||||
|
self.timeout = getattr(settings, 'SIMPLEKEYS_CACHE_TIMEOUT', 25*60*60)
|
||||||
|
|
||||||
|
def get_tokens_and_timestamp(self, key, zone):
|
||||||
|
kz = '{}~{}'.format(key, zone)
|
||||||
|
# once we drop Django 1.8 support we can use
|
||||||
|
# return self.cache.get_or_set(kz, (0, None), self.timeout)
|
||||||
|
val = self.cache.get(kz)
|
||||||
|
if val is None:
|
||||||
|
val = self.cache.add(kz, (0, None), timeout=self.timeout)
|
||||||
|
if val:
|
||||||
|
return self.cache.get(kz)
|
||||||
|
return val
|
||||||
|
|
||||||
|
def set_token_count(self, key, zone, tokens):
|
||||||
|
kz = '{}~{}'.format(key, zone)
|
||||||
|
self.cache.set(kz, (tokens, time.time()), self.timeout)
|
||||||
|
|
||||||
|
def get_and_inc_quota_value(self, key, zone, quota_range):
|
||||||
|
quota_key = '{}~{}~{}'.format(key, zone, quota_range)
|
||||||
|
# once we drop Django 1.8 support we can use
|
||||||
|
# self.cache.get_or_set(quota_key, 0, timeout=self.timeout)
|
||||||
|
if quota_key not in self.cache:
|
||||||
|
self.cache.add(quota_key, 0, timeout=self.timeout)
|
||||||
|
return self.cache.incr(quota_key)
|
||||||
|
|
||||||
|
def get_usage(self, keys=None, days=7):
|
||||||
|
today = datetime.date.today()
|
||||||
|
dates = [(today - datetime.timedelta(days=d)).strftime('%Y%m%d')
|
||||||
|
for d in range(days)]
|
||||||
|
zones = Zone.objects.all().values_list('slug', flat=True)
|
||||||
|
if not keys:
|
||||||
|
keys = Key.objects.all().values_list('key', flat=True)
|
||||||
|
all_keys = ['{}~{}~{}'.format(key, zone, date) for (key, zone, date) in
|
||||||
|
itertools.product(keys, zones, dates)]
|
||||||
|
|
||||||
|
result = {k: {d: Counter() for d in dates} for k in keys}
|
||||||
|
for cache_key, cache_val in self.cache.get_many(all_keys).items():
|
||||||
|
key, zone, date = cache_key.split('~')
|
||||||
|
result[key][date][zone] = cache_val
|
||||||
|
|
||||||
|
return result
|
18
simplekeys/decorators.py
Normal file
18
simplekeys/decorators.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from functools import wraps
|
||||||
|
from .verifier import verify_request
|
||||||
|
|
||||||
|
|
||||||
|
def key_required(zone=None):
|
||||||
|
if not zone:
|
||||||
|
zone = getattr(settings, 'SIMPLEKEYS_DEFAULT_ZONE', 'default')
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
def newfunc(request, *args, **kwargs):
|
||||||
|
resp = verify_request(request, zone)
|
||||||
|
return resp or func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return newfunc
|
||||||
|
|
||||||
|
return decorator
|
14
simplekeys/forms.py
Normal file
14
simplekeys/forms.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django import forms
|
||||||
|
from .models import Key
|
||||||
|
|
||||||
|
|
||||||
|
class KeyRegistrationForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Key
|
||||||
|
fields = ['email', 'name', 'website', 'organization', 'usage']
|
||||||
|
|
||||||
|
|
||||||
|
class KeyConfirmationForm(forms.Form):
|
||||||
|
email = forms.CharField(widget=forms.HiddenInput)
|
||||||
|
key = forms.CharField(widget=forms.HiddenInput)
|
||||||
|
confirm_hash = forms.CharField(widget=forms.HiddenInput)
|
0
simplekeys/management/__init__.py
Normal file
0
simplekeys/management/__init__.py
Normal file
0
simplekeys/management/commands/__init__.py
Normal file
0
simplekeys/management/commands/__init__.py
Normal file
26
simplekeys/management/commands/exportkeys.py
Normal file
26
simplekeys/management/commands/exportkeys.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import csv
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from ...models import Key
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Export API user information.'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--since', dest='since', default=False,
|
||||||
|
help='only dump users since a given date')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
fields = ('key', 'status', 'tier', 'email', 'name', 'organization', 'usage', 'website',
|
||||||
|
'created_at', 'updated_at')
|
||||||
|
|
||||||
|
dw = csv.DictWriter(self.stdout, fields)
|
||||||
|
dw.writeheader()
|
||||||
|
|
||||||
|
qs = Key.objects.all()
|
||||||
|
|
||||||
|
if options['since']:
|
||||||
|
qs = qs.filter(created_at__gte=options['since'])
|
||||||
|
|
||||||
|
for key in qs.order_by('created_at'):
|
||||||
|
dw.writerow({f: getattr(key, f) for f in fields})
|
25
simplekeys/management/commands/usagereport.py
Normal file
25
simplekeys/management/commands/usagereport.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import csv
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Export API usage report.'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--days', dest='days', default=7, help='days of usage to export')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
|
||||||
|
backend = getattr(settings, 'SIMPLEKEYS_RATE_LIMIT_BACKEND',
|
||||||
|
'simplekeys.backends.CacheBackend')
|
||||||
|
backend = import_string(backend)()
|
||||||
|
|
||||||
|
dw = csv.writer(self.stdout)
|
||||||
|
dw.writerow(('key', 'zone', 'date', 'requests'))
|
||||||
|
|
||||||
|
for key, date_zone_num in backend.get_usage(days=int(options['days'])).items():
|
||||||
|
for date, zone_num in date_zone_num.items():
|
||||||
|
for zone, requests in zone_num.items():
|
||||||
|
dw.writerow((key, zone, date, requests))
|
33
simplekeys/middleware.py
Normal file
33
simplekeys/middleware.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import re
|
||||||
|
from .verifier import verify_request
|
||||||
|
try:
|
||||||
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
|
except ImportError:
|
||||||
|
MiddlewareMixin = object
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleKeysMiddleware(MiddlewareMixin):
|
||||||
|
def __init__(self, get_response=None):
|
||||||
|
self._zones = None
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
@property
|
||||||
|
def zones(self):
|
||||||
|
if not self._zones:
|
||||||
|
from django.conf import settings
|
||||||
|
zones = getattr(settings, 'SIMPLEKEYS_ZONE_PATHS',
|
||||||
|
[('.*', 'default')])
|
||||||
|
|
||||||
|
self._zones = [
|
||||||
|
((re.compile(path) if isinstance(path, str) else path), zone)
|
||||||
|
for (path, zone) in zones
|
||||||
|
]
|
||||||
|
return self._zones
|
||||||
|
|
||||||
|
def process_request(self, request):
|
||||||
|
for path, zone in self.zones:
|
||||||
|
if path.match(request.path):
|
||||||
|
return verify_request(request, zone)
|
||||||
|
|
||||||
|
# no paths matched, pass-through
|
||||||
|
return None
|
76
simplekeys/migrations/0001_initial.py
Normal file
76
simplekeys/migrations/0001_initial.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11 on 2017-04-19 01:01
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Key',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('key', models.CharField(default=uuid.uuid4, max_length=40, unique=True)),
|
||||||
|
('status', models.CharField(choices=[('u', 'Unactivated'), ('s', 'Suspended'), ('a', 'Active')], max_length=1)),
|
||||||
|
('email', models.EmailField(max_length=254, unique=True)),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('organization', models.CharField(max_length=100)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Limit',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('quota_period', models.CharField(choices=[('d', 'daily'), ('m', 'monthly')], max_length=1)),
|
||||||
|
('quota_requests', models.PositiveIntegerField()),
|
||||||
|
('requests_per_second', models.PositiveIntegerField()),
|
||||||
|
('burst_size', models.PositiveIntegerField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Tier',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slug', models.SlugField(unique=True)),
|
||||||
|
('name', models.CharField(max_length=50)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Zone',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slug', models.SlugField(unique=True)),
|
||||||
|
('name', models.CharField(max_length=50)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='limit',
|
||||||
|
name='tier',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='limits', to='simplekeys.Tier'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='limit',
|
||||||
|
name='zone',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='limits', to='simplekeys.Zone'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='key',
|
||||||
|
name='tier',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='keys', to='simplekeys.Tier'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='limit',
|
||||||
|
unique_together=set([('tier', 'zone')]),
|
||||||
|
),
|
||||||
|
]
|
30
simplekeys/migrations/0002_auto_20170421_0307.py
Normal file
30
simplekeys/migrations/0002_auto_20170421_0307.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11 on 2017-04-21 03:07
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('simplekeys', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='key',
|
||||||
|
name='usage',
|
||||||
|
field=models.TextField(blank=True, verbose_name='Intended Usage'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='key',
|
||||||
|
name='website',
|
||||||
|
field=models.URLField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='key',
|
||||||
|
name='organization',
|
||||||
|
field=models.CharField(blank=True, max_length=100),
|
||||||
|
),
|
||||||
|
]
|
19
simplekeys/migrations/0003_auto_20181030_0030.py
Normal file
19
simplekeys/migrations/0003_auto_20181030_0030.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 2.0.9 on 2018-10-30 00:30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('simplekeys', '0002_auto_20170421_0307'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='key',
|
||||||
|
name='tier',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='keys', to='simplekeys.Tier'),
|
||||||
|
),
|
||||||
|
]
|
0
simplekeys/migrations/__init__.py
Normal file
0
simplekeys/migrations/__init__.py
Normal file
63
simplekeys/models.py
Normal file
63
simplekeys/models.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
QUOTA_PERIODS = (
|
||||||
|
('d', 'daily'),
|
||||||
|
('m', 'monthly'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
KEY_STATUSES = (
|
||||||
|
('u', 'Unactivated'),
|
||||||
|
('s', 'Suspended'),
|
||||||
|
('a', 'Active'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Tier(models.Model):
|
||||||
|
slug = models.SlugField(max_length=50, unique=True)
|
||||||
|
name = models.CharField(max_length=50)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Zone(models.Model):
|
||||||
|
slug = models.SlugField(max_length=50, unique=True)
|
||||||
|
name = models.CharField(max_length=50)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Limit(models.Model):
|
||||||
|
tier = models.ForeignKey(Tier, related_name='limits', on_delete=models.CASCADE)
|
||||||
|
zone = models.ForeignKey(Zone, related_name='limits', on_delete=models.CASCADE)
|
||||||
|
quota_period = models.CharField(max_length=1, choices=QUOTA_PERIODS)
|
||||||
|
quota_requests = models.PositiveIntegerField()
|
||||||
|
requests_per_second = models.PositiveIntegerField()
|
||||||
|
burst_size = models.PositiveIntegerField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = (
|
||||||
|
('tier', 'zone'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Key(models.Model):
|
||||||
|
key = models.CharField(max_length=40, unique=True, default=uuid.uuid4)
|
||||||
|
status = models.CharField(max_length=1, choices=KEY_STATUSES)
|
||||||
|
tier = models.ForeignKey(Tier, related_name='keys', on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
email = models.EmailField(unique=True)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
organization = models.CharField(max_length=100, blank=True)
|
||||||
|
usage = models.TextField('Intended Usage', blank=True)
|
||||||
|
website = models.URLField(blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return '{} ({})'.format(self.email, self.key)
|
16
simplekeys/templates/simplekeys/confirmation.html
Normal file
16
simplekeys/templates/simplekeys/confirmation.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>API Key Confirmation</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>API Key Confirmation</h1>
|
||||||
|
|
||||||
|
<p> Click Submit to confirm your key: </p>
|
||||||
|
|
||||||
|
<form action="." method="POST">
|
||||||
|
{{ form.as_ul }}
|
||||||
|
<input type="submit">
|
||||||
|
{% csrf_token %}
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
13
simplekeys/templates/simplekeys/confirmation_email.txt
Normal file
13
simplekeys/templates/simplekeys/confirmation_email.txt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{{ key.name }},
|
||||||
|
|
||||||
|
Your API key registration is almost done!
|
||||||
|
|
||||||
|
Your new API key is: {{ key.key }}
|
||||||
|
|
||||||
|
Please visit:
|
||||||
|
|
||||||
|
{{ confirmation_url|safe }}
|
||||||
|
|
||||||
|
to activate your key.
|
||||||
|
|
||||||
|
Thanks!
|
11
simplekeys/templates/simplekeys/confirmed.html
Normal file
11
simplekeys/templates/simplekeys/confirmed.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>API Key Confirmed</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>API Key Confirmed</h1>
|
||||||
|
|
||||||
|
<p> Your key has been activated: {{ key.key }} </p>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
13
simplekeys/templates/simplekeys/register.html
Normal file
13
simplekeys/templates/simplekeys/register.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>API Key Registration</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>API Key Registration</h1>
|
||||||
|
<form action="." method="POST">
|
||||||
|
{{ form.as_ul }}
|
||||||
|
<input type="submit">
|
||||||
|
{% csrf_token %}
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
0
simplekeys/tests/__init__.py
Normal file
0
simplekeys/tests/__init__.py
Normal file
103
simplekeys/tests/test_backends.py
Normal file
103
simplekeys/tests/test_backends.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
|
from ..models import Zone, Key, Tier
|
||||||
|
from ..backends import MemoryBackend, CacheBackend
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryBackendTestCase(TestCase):
|
||||||
|
def get_backend(self):
|
||||||
|
return MemoryBackend()
|
||||||
|
|
||||||
|
def test_get_tokens_and_timestamp_initial(self):
|
||||||
|
b = self.get_backend()
|
||||||
|
self.assertEquals(b.get_tokens_and_timestamp('key', 'zone'), (0, None))
|
||||||
|
|
||||||
|
def test_token_set_and_retrieve(self):
|
||||||
|
b = self.get_backend()
|
||||||
|
with freeze_time('2017-04-17'):
|
||||||
|
b.set_token_count('key', 'zone', 100)
|
||||||
|
tokens, timestamp = b.get_tokens_and_timestamp('key', 'zone')
|
||||||
|
self.assertEquals(tokens, 100)
|
||||||
|
self.assertEquals(int(timestamp), 1492387200) # frozen time
|
||||||
|
|
||||||
|
def test_key_and_zone_independence(self):
|
||||||
|
b = self.get_backend()
|
||||||
|
b.set_token_count('key', 'zone', 100)
|
||||||
|
self.assertEquals(b.get_tokens_and_timestamp('key2', 'zone'),
|
||||||
|
(0, None))
|
||||||
|
self.assertEquals(b.get_tokens_and_timestamp('key', 'zone2'),
|
||||||
|
(0, None))
|
||||||
|
|
||||||
|
def test_get_and_inc_quota(self):
|
||||||
|
b = self.get_backend()
|
||||||
|
self.assertEquals(
|
||||||
|
b.get_and_inc_quota_value('key', 'zone', '20170411'), 1
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
b.get_and_inc_quota_value('key', 'zone', '20170411'), 2
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
b.get_and_inc_quota_value('key', 'zone', '20170412'), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_and_inc_quota_key_and_zone_independence(self):
|
||||||
|
b = self.get_backend()
|
||||||
|
self.assertEquals(
|
||||||
|
b.get_and_inc_quota_value('key', 'zone', '20170411'), 1
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
b.get_and_inc_quota_value('key2', 'zone', '20170411'), 1
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
b.get_and_inc_quota_value('key', 'zone2', '20170411'), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CacheBackendTestCase(MemoryBackendTestCase):
|
||||||
|
""" do the same tests as MemoryBackendTestCase but w/ CacheBackend """
|
||||||
|
|
||||||
|
def get_backend(self):
|
||||||
|
# ensure we have a fresh cache backend each time
|
||||||
|
c = CacheBackend()
|
||||||
|
c.cache.clear()
|
||||||
|
return c
|
||||||
|
|
||||||
|
def test_get_usage(self):
|
||||||
|
Zone.objects.create(slug='default', name='Default')
|
||||||
|
Zone.objects.create(slug='special', name='Special')
|
||||||
|
tier = Tier.objects.create(slug='default')
|
||||||
|
Key.objects.create(key='key1', tier=tier, email='key1@example.com')
|
||||||
|
Key.objects.create(key='key2', tier=tier, email='key2@example.com')
|
||||||
|
b = self.get_backend()
|
||||||
|
|
||||||
|
# key, zone, day, num
|
||||||
|
usage = [
|
||||||
|
# Key 1 usage
|
||||||
|
('key1', 'default', '20170501', 20),
|
||||||
|
('key1', 'default', '20170502', 200),
|
||||||
|
('key1', 'default', '20170503', 20),
|
||||||
|
('key1', 'special', '20170502', 5),
|
||||||
|
# Key 2 usage
|
||||||
|
('key2', 'special', '20170501', 1),
|
||||||
|
('key2', 'special', '20170502', 1),
|
||||||
|
('key2', 'special', '20170503', 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
for key, zone, day, num in usage:
|
||||||
|
for _ in range(num):
|
||||||
|
b.get_and_inc_quota_value(key, zone, day)
|
||||||
|
|
||||||
|
with freeze_time('2017-05-05'):
|
||||||
|
key1usage = b.get_usage()['key1']
|
||||||
|
assert key1usage['20170501']['default'] == 20
|
||||||
|
assert key1usage['20170502']['default'] == 200
|
||||||
|
assert key1usage['20170503']['default'] == 20
|
||||||
|
assert key1usage['20170502']['special'] == 5
|
||||||
|
assert key1usage['20170501']['special'] == 0
|
||||||
|
|
||||||
|
# ensure we only get requested data
|
||||||
|
with freeze_time('2017-05-15'):
|
||||||
|
usage = b.get_usage(keys=['key1'], days=2)
|
||||||
|
assert len(usage) == 1
|
||||||
|
assert len(usage['key1']) == 2
|
43
simplekeys/tests/test_exportkeys.py
Normal file
43
simplekeys/tests/test_exportkeys.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import csv
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.test.utils import captured_stdout
|
||||||
|
from django.core.management import call_command
|
||||||
|
from ..models import Tier, Key
|
||||||
|
|
||||||
|
|
||||||
|
class ExportKeysTestCase(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.bronze = Tier.objects.create(slug='bronze', name='Bronze')
|
||||||
|
self.gold = Tier.objects.create(slug='gold', name='Gold')
|
||||||
|
|
||||||
|
Key.objects.create(key='one', status='a', tier=self.bronze,
|
||||||
|
email='one@example.com', name='User One')
|
||||||
|
Key.objects.create(key='two', status='a', tier=self.gold,
|
||||||
|
email='two@example.com', name='User Two')
|
||||||
|
Key.objects.create(key='three', status='u', tier=self.bronze,
|
||||||
|
email='three@example.com', name='User Three')
|
||||||
|
Key.objects.create(key='four', status='a', tier=self.gold,
|
||||||
|
email='four@example.com', name='User Four')
|
||||||
|
|
||||||
|
def test_basic_export(self):
|
||||||
|
with captured_stdout() as stdout:
|
||||||
|
call_command('exportkeys')
|
||||||
|
|
||||||
|
stdout.seek(0)
|
||||||
|
|
||||||
|
data = list(csv.DictReader(stdout))
|
||||||
|
self.assertEquals(len(data), 4)
|
||||||
|
self.assertEquals(data[0]['key'], 'one')
|
||||||
|
self.assertEquals(data[1]['tier'], 'Gold')
|
||||||
|
self.assertEquals(data[2]['status'], 'u')
|
||||||
|
self.assertEquals(data[3]['email'], 'four@example.com')
|
||||||
|
|
||||||
|
def test_export_flags(self):
|
||||||
|
offset = Key.objects.all().order_by('created_at')[1]
|
||||||
|
|
||||||
|
with captured_stdout() as stdout:
|
||||||
|
call_command('exportkeys', '--since=' + offset.created_at.isoformat())
|
||||||
|
stdout.seek(0)
|
||||||
|
data = list(csv.DictReader(stdout))
|
||||||
|
self.assertEquals(len(data), 3)
|
83
simplekeys/tests/test_middleware_and_decorator.py
Normal file
83
simplekeys/tests/test_middleware_and_decorator.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from ..models import Tier, Zone, Key
|
||||||
|
from ..verifier import backend
|
||||||
|
|
||||||
|
|
||||||
|
class ViewTestCase(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
backend.reset()
|
||||||
|
|
||||||
|
self.bronze = Tier.objects.create(slug='bronze', name='Bronze')
|
||||||
|
self.default_zone = Zone.objects.create(slug='default', name='Default')
|
||||||
|
self.bronze.limits.create(
|
||||||
|
zone=self.default_zone,
|
||||||
|
quota_requests=10,
|
||||||
|
quota_period='d',
|
||||||
|
requests_per_second=2,
|
||||||
|
burst_size=10,
|
||||||
|
)
|
||||||
|
self.gold = Tier.objects.create(slug='gold', name='Gold')
|
||||||
|
self.special = Zone.objects.create(slug='special', name='Special')
|
||||||
|
self.gold.limits.create(
|
||||||
|
zone=self.special,
|
||||||
|
quota_requests=10,
|
||||||
|
quota_period='d',
|
||||||
|
requests_per_second=2,
|
||||||
|
burst_size=10,
|
||||||
|
)
|
||||||
|
Key.objects.create(
|
||||||
|
key='bronze',
|
||||||
|
status='a',
|
||||||
|
tier=self.bronze,
|
||||||
|
email='bronze1@example.com',
|
||||||
|
)
|
||||||
|
Key.objects.create(
|
||||||
|
key='gold',
|
||||||
|
status='a',
|
||||||
|
tier=self.gold,
|
||||||
|
email='gold@example.com',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_view_no_key(self):
|
||||||
|
response = self.client.get('/example/')
|
||||||
|
self.assertEquals(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_view_key_header(self):
|
||||||
|
response = self.client.get('/example/', HTTP_X_API_KEY='bronze')
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_view_key_param(self):
|
||||||
|
response = self.client.get('/example/?apikey=bronze')
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_view_zone(self):
|
||||||
|
# ensure that bronze can't get here, but gold can
|
||||||
|
response = self.client.get('/special/?apikey=bronze')
|
||||||
|
self.assertEquals(response.status_code, 403)
|
||||||
|
response = self.client.get('/special/?apikey=gold')
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_view_key_429(self):
|
||||||
|
for x in range(10):
|
||||||
|
response = self.client.get('/example/?apikey=bronze')
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
# 11th request, exceeds burst
|
||||||
|
response = self.client.get('/example/?apikey=bronze')
|
||||||
|
self.assertEquals(response.status_code, 429)
|
||||||
|
|
||||||
|
# ... we won't test everything else, verifier tests take care of that
|
||||||
|
|
||||||
|
def test_view_protected_via_middleware(self):
|
||||||
|
# make sure middleware doesn't wind up protecting everything
|
||||||
|
response = self.client.get('/via_middleware/')
|
||||||
|
self.assertEquals(response.status_code, 403)
|
||||||
|
|
||||||
|
# and with key
|
||||||
|
response = self.client.get('/via_middleware/?apikey=bronze')
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_view_unprotected(self):
|
||||||
|
# make sure middleware doesn't wind up protecting everything
|
||||||
|
response = self.client.get('/unprotected/')
|
||||||
|
self.assertEquals(response.status_code, 200)
|
47
simplekeys/tests/test_usagereport.py
Normal file
47
simplekeys/tests/test_usagereport.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import csv
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.test.utils import captured_stdout
|
||||||
|
from django.core.management import call_command
|
||||||
|
from freezegun import freeze_time
|
||||||
|
from ..models import Tier, Key, Zone
|
||||||
|
from ..backends import CacheBackend
|
||||||
|
|
||||||
|
|
||||||
|
class UsageReportTestCase(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
b = CacheBackend()
|
||||||
|
Zone.objects.create(slug='default', name='Default')
|
||||||
|
Zone.objects.create(slug='special', name='Special')
|
||||||
|
tier = Tier.objects.create(slug='default')
|
||||||
|
Key.objects.create(key='key1', tier=tier, email='key1@example.com')
|
||||||
|
Key.objects.create(key='key2', tier=tier, email='key2@example.com')
|
||||||
|
|
||||||
|
# key, zone, day, num
|
||||||
|
self.usage = (
|
||||||
|
# Key 1 usage
|
||||||
|
('key1', 'default', '20170501', '20'),
|
||||||
|
('key1', 'default', '20170502', '200'),
|
||||||
|
('key1', 'default', '20170503', '20'),
|
||||||
|
('key1', 'special', '20170502', '5'),
|
||||||
|
# Key 2 usage
|
||||||
|
('key2', 'special', '20170501', '1'),
|
||||||
|
('key2', 'special', '20170502', '1'),
|
||||||
|
('key2', 'special', '20170503', '1'),
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, zone, day, num in self.usage:
|
||||||
|
for _ in range(int(num)):
|
||||||
|
b.get_and_inc_quota_value(key, zone, day)
|
||||||
|
|
||||||
|
@override_settings(SIMPLEKEYS_RATE_LIMIT_BACKEND='simplekeys.backends.CacheBackend')
|
||||||
|
def test_basic_report(self):
|
||||||
|
with freeze_time('2017-05-05'):
|
||||||
|
with captured_stdout() as stdout:
|
||||||
|
call_command('usagereport')
|
||||||
|
|
||||||
|
stdout.seek(0)
|
||||||
|
|
||||||
|
data = tuple(tuple(row) for row in csv.reader(stdout))[1:]
|
||||||
|
self.assertEquals(len(data), 7)
|
||||||
|
self.assertEquals(set(data), set(self.usage))
|
218
simplekeys/tests/test_verifier.py
Normal file
218
simplekeys/tests/test_verifier.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import datetime
|
||||||
|
from django.test import TestCase
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
|
from ..models import Tier, Zone, Key
|
||||||
|
from ..verifier import (verify, VerificationError, RateLimitError, QuotaError,
|
||||||
|
backend)
|
||||||
|
|
||||||
|
|
||||||
|
class UsageTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.bronze = Tier.objects.create(slug='bronze', name='Bronze')
|
||||||
|
self.gold = Tier.objects.create(slug='gold', name='Gold')
|
||||||
|
self.default_zone = Zone.objects.create(slug='default', name='Default')
|
||||||
|
self.premium_zone = Zone.objects.create(slug='premium', name='Premium')
|
||||||
|
self.secret_zone = Zone.objects.create(slug='secret', name='Secret')
|
||||||
|
|
||||||
|
# only available on memory backend
|
||||||
|
backend.reset()
|
||||||
|
|
||||||
|
self.bronze.limits.create(
|
||||||
|
zone=self.default_zone,
|
||||||
|
quota_requests=100,
|
||||||
|
quota_period='d',
|
||||||
|
requests_per_second=2,
|
||||||
|
burst_size=10,
|
||||||
|
)
|
||||||
|
self.bronze.limits.create(
|
||||||
|
zone=self.premium_zone,
|
||||||
|
quota_requests=10,
|
||||||
|
quota_period='d',
|
||||||
|
requests_per_second=1,
|
||||||
|
burst_size=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.gold.limits.create(
|
||||||
|
zone=self.default_zone,
|
||||||
|
quota_requests=1000,
|
||||||
|
quota_period='d',
|
||||||
|
requests_per_second=5,
|
||||||
|
burst_size=10,
|
||||||
|
)
|
||||||
|
self.gold.limits.create(
|
||||||
|
zone=self.premium_zone,
|
||||||
|
quota_requests=10,
|
||||||
|
quota_period='d',
|
||||||
|
requests_per_second=5,
|
||||||
|
burst_size=10,
|
||||||
|
)
|
||||||
|
self.gold.limits.create(
|
||||||
|
zone=self.secret_zone,
|
||||||
|
quota_requests=10,
|
||||||
|
quota_period='m', # monthly limit for secret zone
|
||||||
|
requests_per_second=5,
|
||||||
|
burst_size=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
Key.objects.create(
|
||||||
|
key='bronze1',
|
||||||
|
status='a',
|
||||||
|
tier=self.bronze,
|
||||||
|
email='bronze1@example.com',
|
||||||
|
)
|
||||||
|
Key.objects.create(
|
||||||
|
key='bronze2',
|
||||||
|
status='a',
|
||||||
|
tier=self.bronze,
|
||||||
|
email='bronze2@example.com',
|
||||||
|
)
|
||||||
|
Key.objects.create(
|
||||||
|
key='gold',
|
||||||
|
status='a',
|
||||||
|
tier=self.gold,
|
||||||
|
email='gold@example.com',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_verifier_bad_key(self):
|
||||||
|
self.assertRaises(VerificationError, verify, 'badkey', 'bronze')
|
||||||
|
|
||||||
|
def test_verifier_inactive_key(self):
|
||||||
|
Key.objects.create(
|
||||||
|
key='newkey',
|
||||||
|
status='u',
|
||||||
|
tier=self.gold,
|
||||||
|
email='new@example.com',
|
||||||
|
)
|
||||||
|
self.assertRaises(VerificationError, verify, 'newkey', 'bronze')
|
||||||
|
|
||||||
|
def test_verifier_suspended_key(self):
|
||||||
|
Key.objects.create(
|
||||||
|
key='badactor',
|
||||||
|
status='s',
|
||||||
|
tier=self.gold,
|
||||||
|
email='new@example.com',
|
||||||
|
)
|
||||||
|
self.assertRaises(VerificationError, verify, 'badactor', 'bronze')
|
||||||
|
|
||||||
|
def test_verifier_zone_access(self):
|
||||||
|
# gold has access, bronze doesn't
|
||||||
|
self.assert_(verify('gold', 'secret'))
|
||||||
|
self.assertRaises(VerificationError, verify, 'bronze1', 'secret')
|
||||||
|
|
||||||
|
def test_verifier_rate_limit(self):
|
||||||
|
with freeze_time() as frozen_dt:
|
||||||
|
# to start - we should have full capacity for a burst of 10
|
||||||
|
for x in range(10):
|
||||||
|
verify('bronze1', 'default')
|
||||||
|
|
||||||
|
# this next one should raise an exception
|
||||||
|
self.assertRaises(RateLimitError, verify, 'bronze1', 'default')
|
||||||
|
|
||||||
|
# let's go forward 1sec, this will let the bucket get 2 more tokens
|
||||||
|
frozen_dt.tick()
|
||||||
|
|
||||||
|
# two more, then limited
|
||||||
|
verify('bronze1', 'default')
|
||||||
|
verify('bronze1', 'default')
|
||||||
|
self.assertRaises(RateLimitError, verify, 'bronze1', 'default')
|
||||||
|
|
||||||
|
def test_verifier_rate_limit_full_refill(self):
|
||||||
|
with freeze_time() as frozen_dt:
|
||||||
|
# let's use the premium zone now - 1req/sec. & burst of 2
|
||||||
|
verify('bronze1', 'premium')
|
||||||
|
verify('bronze1', 'premium')
|
||||||
|
self.assertRaises(RateLimitError, verify, 'bronze1', 'premium')
|
||||||
|
|
||||||
|
# in 5 seconds - ensure we haven't let capacity surpass burst rate
|
||||||
|
frozen_dt.tick(delta=datetime.timedelta(seconds=5))
|
||||||
|
verify('bronze1', 'premium')
|
||||||
|
verify('bronze1', 'premium')
|
||||||
|
self.assertRaises(RateLimitError, verify, 'bronze1', 'premium')
|
||||||
|
|
||||||
|
def test_verifier_rate_limit_key_dependent(self):
|
||||||
|
# ensure that the rate limit is unique per-key
|
||||||
|
|
||||||
|
# each key is able to get both of its requests in, no waiting
|
||||||
|
verify('bronze1', 'premium')
|
||||||
|
verify('bronze1', 'premium')
|
||||||
|
verify('bronze2', 'premium')
|
||||||
|
verify('bronze2', 'premium')
|
||||||
|
|
||||||
|
self.assertRaises(RateLimitError, verify, 'bronze1', 'premium')
|
||||||
|
self.assertRaises(RateLimitError, verify, 'bronze2', 'premium')
|
||||||
|
|
||||||
|
def test_verifier_rate_limit_zone_dependent(self):
|
||||||
|
# ensure that the rate limit is unique per-zone
|
||||||
|
|
||||||
|
# key is able to get both of its requests in, no waiting
|
||||||
|
verify('bronze1', 'premium')
|
||||||
|
verify('bronze1', 'premium')
|
||||||
|
# and can hit another zone no problem
|
||||||
|
verify('bronze1', 'default')
|
||||||
|
|
||||||
|
# but premium is still exhausted
|
||||||
|
self.assertRaises(RateLimitError, verify, 'bronze1', 'premium')
|
||||||
|
|
||||||
|
def test_verifier_quota_day(self):
|
||||||
|
# let's pretend a day has passed, we can call again!
|
||||||
|
with freeze_time('2017-04-17') as frozen_dt:
|
||||||
|
# gold can hit premium only 10x/day (burst is also 10)
|
||||||
|
for x in range(10):
|
||||||
|
verify('gold', 'premium')
|
||||||
|
|
||||||
|
# after 1 second, should have another token
|
||||||
|
frozen_dt.tick()
|
||||||
|
|
||||||
|
# but still no good- we've hit our daily limit
|
||||||
|
self.assertRaises(QuotaError, verify, 'gold', 'premium')
|
||||||
|
|
||||||
|
frozen_dt.tick(delta=datetime.timedelta(days=1))
|
||||||
|
for x in range(10):
|
||||||
|
verify('gold', 'premium')
|
||||||
|
|
||||||
|
def test_verifier_quota_month(self):
|
||||||
|
# need to make sure we aren't on the last day of a month
|
||||||
|
with freeze_time('2017-04-17') as frozen_dt:
|
||||||
|
# gold can hit secret only 10x/month (burst is also 10)
|
||||||
|
for x in range(10):
|
||||||
|
verify('gold', 'secret')
|
||||||
|
|
||||||
|
# after 1 second, should have another token
|
||||||
|
frozen_dt.tick()
|
||||||
|
|
||||||
|
# but still no good- we've hit our monthly limit
|
||||||
|
self.assertRaises(QuotaError, verify, 'gold', 'secret')
|
||||||
|
|
||||||
|
# let's pretend a day has passed... still no good
|
||||||
|
frozen_dt.tick(delta=datetime.timedelta(days=1))
|
||||||
|
self.assertRaises(QuotaError, verify, 'gold', 'secret')
|
||||||
|
|
||||||
|
# but a month later? we're good!
|
||||||
|
frozen_dt.tick(delta=datetime.timedelta(days=30))
|
||||||
|
for x in range(10):
|
||||||
|
verify('gold', 'secret')
|
||||||
|
|
||||||
|
def test_verifier_quota_key_dependent(self):
|
||||||
|
with freeze_time('2017-04-17') as frozen_dt:
|
||||||
|
# 1 req/sec from bronze1 and bronze2
|
||||||
|
for x in range(10):
|
||||||
|
verify('bronze1', 'premium')
|
||||||
|
verify('bronze2', 'premium')
|
||||||
|
frozen_dt.tick()
|
||||||
|
|
||||||
|
# 11th in either should be a problem - day total is exhausted
|
||||||
|
self.assertRaises(QuotaError, verify, 'bronze1', 'premium')
|
||||||
|
self.assertRaises(QuotaError, verify, 'bronze2', 'premium')
|
||||||
|
|
||||||
|
def test_verifier_quota_zone_dependent(self):
|
||||||
|
with freeze_time('2017-04-17') as frozen_dt:
|
||||||
|
# should be able to do 10 in premium & secret without issue
|
||||||
|
for x in range(10):
|
||||||
|
verify('gold', 'premium')
|
||||||
|
verify('gold', 'secret')
|
||||||
|
frozen_dt.tick()
|
||||||
|
|
||||||
|
# 11th in either should be a problem
|
||||||
|
self.assertRaises(QuotaError, verify, 'gold', 'premium')
|
||||||
|
self.assertRaises(QuotaError, verify, 'gold', 'secret')
|
176
simplekeys/tests/test_views.py
Normal file
176
simplekeys/tests/test_views.py
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.core import mail
|
||||||
|
from ..models import Tier, Zone, Key
|
||||||
|
from ..verifier import backend
|
||||||
|
from ..views import _get_confirm_hash
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationViewTestCase(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
backend.reset()
|
||||||
|
|
||||||
|
default_zone = Zone.objects.create(slug='default', name='default')
|
||||||
|
default_tier = Tier.objects.create(slug='default', name='default')
|
||||||
|
default_tier.limits.create(
|
||||||
|
zone=default_zone,
|
||||||
|
quota_requests=10,
|
||||||
|
quota_period='d',
|
||||||
|
requests_per_second=2,
|
||||||
|
burst_size=10,
|
||||||
|
)
|
||||||
|
Tier.objects.create(slug='special', name='special')
|
||||||
|
|
||||||
|
def test_get(self):
|
||||||
|
# ensure form is present
|
||||||
|
response = self.client.get('/register/')
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertIn('form', response.context)
|
||||||
|
|
||||||
|
def test_valid_post(self):
|
||||||
|
email = 'amy@example.com'
|
||||||
|
response = self.client.post('/register/',
|
||||||
|
{'email': email,
|
||||||
|
'name': 'Amy',
|
||||||
|
'organization': 'ACME'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# ensure key is created
|
||||||
|
key = Key.objects.get(email=email)
|
||||||
|
self.assertEquals(key.email, email)
|
||||||
|
self.assertEquals(key.name, 'Amy')
|
||||||
|
self.assertEquals(key.organization, 'ACME')
|
||||||
|
self.assertEquals(key.status, 'u')
|
||||||
|
self.assertEquals(key.tier.slug, 'default')
|
||||||
|
|
||||||
|
# ensure email is sent and contains key
|
||||||
|
self.assertEquals(len(mail.outbox), 1)
|
||||||
|
self.assertEquals(len(key.key), 36) # default is UUID
|
||||||
|
self.assertIn(key.key, str(mail.outbox[0].message()))
|
||||||
|
|
||||||
|
# ensure redirect to / (OK that it is a 404)
|
||||||
|
self.assertRedirects(response, '/', target_status_code=404)
|
||||||
|
|
||||||
|
def test_invalid_post(self):
|
||||||
|
# invalid post - missing email
|
||||||
|
response = self.client.post('/register/',
|
||||||
|
{'name': 'Amy',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# response should be the page w/ errors on the form
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertEquals(len(response.context['form'].errors), 1)
|
||||||
|
|
||||||
|
# no email is sent
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
self.assertEqual(Key.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_custom_tier(self):
|
||||||
|
email = 'amy@example.com'
|
||||||
|
self.client.post('/register-special/', # tier overridden
|
||||||
|
{'email': email,
|
||||||
|
'name': 'Amy',
|
||||||
|
'organization': 'ACME'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# ensure key is created in right tier
|
||||||
|
key = Key.objects.get(email=email)
|
||||||
|
self.assertEquals(key.tier.slug, 'special')
|
||||||
|
|
||||||
|
def test_relative_confirmation_url(self):
|
||||||
|
self.client.post('/register/',
|
||||||
|
{'email': 'amy@example.com',
|
||||||
|
'name': 'Amy',
|
||||||
|
'organization': 'ACME'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# is built from Site URL and /confirm/
|
||||||
|
confirmation_url = 'https://example.com/confirm/?'
|
||||||
|
self.assertIn(confirmation_url, str(mail.outbox[0].message()))
|
||||||
|
|
||||||
|
def test_absolute_confirmation_url(self):
|
||||||
|
self.client.post('/register-special/',
|
||||||
|
{'email': 'amy@example.com',
|
||||||
|
'name': 'Amy',
|
||||||
|
'organization': 'ACME'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# absolute URL
|
||||||
|
confirmation_url = 'https://confirm.example.com/special-confirm/?'
|
||||||
|
self.assertIn(confirmation_url, str(mail.outbox[0].message()))
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmationViewTestCase(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
backend.reset()
|
||||||
|
|
||||||
|
default_zone = Zone.objects.create(slug='default', name='default')
|
||||||
|
default_tier = Tier.objects.create(slug='default', name='default')
|
||||||
|
default_tier.limits.create(
|
||||||
|
zone=default_zone,
|
||||||
|
quota_requests=10,
|
||||||
|
quota_period='d',
|
||||||
|
requests_per_second=2,
|
||||||
|
burst_size=10,
|
||||||
|
)
|
||||||
|
self.key = 'sample'
|
||||||
|
self.email = 'amy@example.com'
|
||||||
|
self.key_obj = Key.objects.create(status='u', key=self.key,
|
||||||
|
email=self.email,
|
||||||
|
tier=default_tier
|
||||||
|
)
|
||||||
|
self.hash = _get_confirm_hash(self.key, self.email)
|
||||||
|
|
||||||
|
def test_get(self):
|
||||||
|
response = self.client.get(
|
||||||
|
'/confirm/?key={}&email={}&confirm_hash={}'.format(
|
||||||
|
self.key, self.email, self.hash
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# get a response pointing us towards a POST to the same data
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertIn('form', response.context)
|
||||||
|
self.assertIn(self.key, response.content.decode())
|
||||||
|
self.assertIn(self.email, response.content.decode())
|
||||||
|
self.assertIn(self.hash, response.content.decode())
|
||||||
|
|
||||||
|
def test_get_invalid(self):
|
||||||
|
response = self.client.get(
|
||||||
|
'/confirm/?key={}&email={}'.format(
|
||||||
|
self.key, self.email,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# hash isn't checked on GET, but still must be present
|
||||||
|
self.assertEquals(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_post(self):
|
||||||
|
response = self.client.post('/confirm/',
|
||||||
|
{'key': self.key,
|
||||||
|
'email': self.email,
|
||||||
|
'confirm_hash': self.hash})
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertIn(self.key, response.content.decode())
|
||||||
|
# status is updated to active
|
||||||
|
self.assertEquals(Key.objects.get(key=self.key).status, 'a')
|
||||||
|
|
||||||
|
def test_post_invalid(self):
|
||||||
|
response = self.client.post('/confirm/',
|
||||||
|
{'key': self.key,
|
||||||
|
'email': self.email,
|
||||||
|
'confirm_hash': 'bad-hash'})
|
||||||
|
self.assertEquals(response.status_code, 400)
|
||||||
|
# status is unchanged
|
||||||
|
self.assertEquals(Key.objects.get(key=self.key).status, 'u')
|
||||||
|
|
||||||
|
def test_post_suspended_key(self):
|
||||||
|
self.key_obj.status = 's'
|
||||||
|
self.key_obj.save()
|
||||||
|
response = self.client.post('/confirm/',
|
||||||
|
{'key': self.key,
|
||||||
|
'email': self.email,
|
||||||
|
'confirm_hash': self.hash})
|
||||||
|
# don't let them update a suspended key
|
||||||
|
self.assertEquals(response.status_code, 400)
|
||||||
|
self.assertEquals(Key.objects.get(key=self.key).status, 's')
|
24
simplekeys/tests/urls.py
Normal file
24
simplekeys/tests/urls.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
URLs only for test purposes
|
||||||
|
"""
|
||||||
|
from django.conf.urls import url
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from simplekeys.views import RegistrationView, ConfirmationView
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
url(r'^admin/', admin.site.urls),
|
||||||
|
url(r'^example/$', views.example),
|
||||||
|
url(r'^special/$', views.special),
|
||||||
|
url(r'^unprotected/$', views.unprotected),
|
||||||
|
url(r'^via_middleware/$', views.via_middleware),
|
||||||
|
|
||||||
|
url(r'^register/$', RegistrationView.as_view()),
|
||||||
|
url(r'^register-special/$', RegistrationView.as_view(
|
||||||
|
tier='special',
|
||||||
|
confirmation_url='https://confirm.example.com/special-confirm/',
|
||||||
|
)),
|
||||||
|
url(r'^confirm/$', ConfirmationView.as_view()),
|
||||||
|
]
|
23
simplekeys/tests/views.py
Normal file
23
simplekeys/tests/views.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"""
|
||||||
|
views only for test purposes
|
||||||
|
"""
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from simplekeys.decorators import key_required
|
||||||
|
|
||||||
|
|
||||||
|
@key_required()
|
||||||
|
def example(request):
|
||||||
|
return JsonResponse({'response': 'OK'})
|
||||||
|
|
||||||
|
|
||||||
|
@key_required(zone='special')
|
||||||
|
def special(request):
|
||||||
|
return JsonResponse({'response': 'special'})
|
||||||
|
|
||||||
|
|
||||||
|
def via_middleware(request):
|
||||||
|
return JsonResponse({'response': 'OK'})
|
||||||
|
|
||||||
|
|
||||||
|
def unprotected(request):
|
||||||
|
return JsonResponse({'response': 'OK'})
|
102
simplekeys/verifier.py
Normal file
102
simplekeys/verifier.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import division
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
from .models import Key, Limit
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# load backend from setting
|
||||||
|
backend = getattr(settings, 'SIMPLEKEYS_RATE_LIMIT_BACKEND',
|
||||||
|
'simplekeys.backends.CacheBackend')
|
||||||
|
backend = import_string(backend)()
|
||||||
|
|
||||||
|
|
||||||
|
def verify(key, zone):
|
||||||
|
# ensure we have a verified key w/ access to the zone
|
||||||
|
try:
|
||||||
|
# could also do this w/ new subquery expressions in 1.11
|
||||||
|
kobj = Key.objects.get(key=key, status='a')
|
||||||
|
limit = Limit.objects.get(tier=kobj.tier, zone__slug=zone)
|
||||||
|
except Key.DoesNotExist:
|
||||||
|
raise VerificationError('no valid key')
|
||||||
|
except Limit.DoesNotExist:
|
||||||
|
raise VerificationError('key does not have access to zone {}'.format(
|
||||||
|
zone
|
||||||
|
))
|
||||||
|
|
||||||
|
# enforce rate limiting - will raise RateLimitError if exhausted
|
||||||
|
# replenish first
|
||||||
|
tokens, last_time = backend.get_tokens_and_timestamp(key, zone)
|
||||||
|
|
||||||
|
if last_time is None:
|
||||||
|
# if this is the first time, fill the bucket
|
||||||
|
tokens = limit.burst_size
|
||||||
|
else:
|
||||||
|
# increment bucket, careful not to overfill
|
||||||
|
tokens = min(
|
||||||
|
tokens + (limit.requests_per_second * (time.time() - last_time)),
|
||||||
|
limit.burst_size
|
||||||
|
)
|
||||||
|
|
||||||
|
# now try to decrement count
|
||||||
|
if tokens >= 1:
|
||||||
|
tokens -= 1
|
||||||
|
backend.set_token_count(key, zone, tokens)
|
||||||
|
else:
|
||||||
|
raise RateLimitError('exhausted tokens: {} req/sec, burst {}'.format(
|
||||||
|
limit.requests_per_second, limit.burst_size
|
||||||
|
))
|
||||||
|
|
||||||
|
# enforce daily/monthly quotas
|
||||||
|
if limit.quota_period == 'd':
|
||||||
|
quota_range = datetime.datetime.utcnow().strftime('%Y%m%d')
|
||||||
|
elif limit.quota_period == 'm':
|
||||||
|
quota_range = datetime.datetime.utcnow().strftime('%Y%m')
|
||||||
|
|
||||||
|
if (backend.get_and_inc_quota_value(key, zone, quota_range) >
|
||||||
|
limit.quota_requests):
|
||||||
|
raise QuotaError('quota exceeded: {}/{}'.format(
|
||||||
|
limit.quota_requests, limit.get_quota_period_display()
|
||||||
|
))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def verify_request(request, zone):
|
||||||
|
key = request.META.get(getattr(settings, 'SIMPLEKEYS_HEADER',
|
||||||
|
'HTTP_X_API_KEY'))
|
||||||
|
note = getattr(settings, 'SIMPLEKEYS_ERROR_NOTE', None)
|
||||||
|
|
||||||
|
if not key:
|
||||||
|
key = request.GET.get(getattr(settings, 'SIMPLEKEYS_QUERY_PARAM',
|
||||||
|
'apikey'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
verify(key, zone)
|
||||||
|
except VerificationError as e:
|
||||||
|
return JsonResponse({'error': str(e), 'note': note},
|
||||||
|
status=403)
|
||||||
|
except RateLimitError as e:
|
||||||
|
return JsonResponse({'error': str(e), 'note': note},
|
||||||
|
status=429)
|
||||||
|
except QuotaError as e:
|
||||||
|
return JsonResponse({'error': str(e), 'note': note},
|
||||||
|
status=429)
|
||||||
|
|
||||||
|
# pass through
|
||||||
|
return None
|
122
simplekeys/views.py
Normal file
122
simplekeys/views.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import hashlib
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.shortcuts import render, redirect
|
||||||
|
from django.template import loader
|
||||||
|
from django.views.generic import View
|
||||||
|
from django.http import HttpResponseBadRequest
|
||||||
|
|
||||||
|
from .forms import KeyRegistrationForm, KeyConfirmationForm
|
||||||
|
from .models import Tier, Key
|
||||||
|
|
||||||
|
|
||||||
|
def _get_confirm_hash(key, email):
|
||||||
|
value = '{}{}{}'.format(key, email, settings.SECRET_KEY)
|
||||||
|
return hashlib.sha256(value.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationView(View):
|
||||||
|
"""
|
||||||
|
present user with a form to fill out to get a key
|
||||||
|
|
||||||
|
upon submission, send an email sending user to confirmation page
|
||||||
|
"""
|
||||||
|
template_name = "simplekeys/register.html"
|
||||||
|
email_subject = 'API Key Registration'
|
||||||
|
email_message_template = 'simplekeys/confirmation_email.txt'
|
||||||
|
from_email = settings.DEFAULT_FROM_EMAIL
|
||||||
|
tier = 'default'
|
||||||
|
redirect = '/'
|
||||||
|
confirmation_url = '/confirm/'
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return render(request, self.template_name,
|
||||||
|
{'form': KeyRegistrationForm()})
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
form = KeyRegistrationForm(request.POST)
|
||||||
|
|
||||||
|
if not form.is_valid():
|
||||||
|
return render(request, self.template_name,
|
||||||
|
{'form': form})
|
||||||
|
|
||||||
|
# go ahead w/ creation
|
||||||
|
key = form.instance
|
||||||
|
key.tier = Tier.objects.get(slug=self.tier)
|
||||||
|
# TODO: option to override this and avoid sending email?
|
||||||
|
key.status = 'u'
|
||||||
|
key.save()
|
||||||
|
|
||||||
|
# send email & redirect user
|
||||||
|
confirm_hash = _get_confirm_hash(key.key, key.email)
|
||||||
|
|
||||||
|
# if URL is relative, make absolute
|
||||||
|
if not self.confirmation_url.startswith(('http:', 'https:')):
|
||||||
|
confirmation_url = '{protocol}://{site}{confirmation_url}'.format(
|
||||||
|
protocol='https' if request.is_secure else 'http',
|
||||||
|
site=Site.objects.get_current().domain,
|
||||||
|
confirmation_url=self.confirmation_url
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
confirmation_url = self.confirmation_url
|
||||||
|
|
||||||
|
confirmation_url = (
|
||||||
|
'{base}?key={key}&email={email}&confirm_hash={confirm_hash}'
|
||||||
|
).format(
|
||||||
|
base=confirmation_url,
|
||||||
|
key=key.key,
|
||||||
|
email=key.email,
|
||||||
|
confirm_hash=confirm_hash
|
||||||
|
)
|
||||||
|
message = loader.render_to_string(
|
||||||
|
self.email_message_template,
|
||||||
|
{'key': key, 'confirmation_url': confirmation_url}
|
||||||
|
)
|
||||||
|
|
||||||
|
send_mail(self.email_subject,
|
||||||
|
message,
|
||||||
|
self.from_email,
|
||||||
|
[key.email])
|
||||||
|
|
||||||
|
return redirect(self.redirect)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmationView(View):
|
||||||
|
"""
|
||||||
|
present user with a simple form that just needs to be submitted
|
||||||
|
to activate the key (don't do activation on GET to prevent
|
||||||
|
email clients from clicking link)
|
||||||
|
"""
|
||||||
|
|
||||||
|
confirmation_template_name = "simplekeys/confirmation.html"
|
||||||
|
confirmed_template_name = "simplekeys/confirmed.html"
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
form = KeyConfirmationForm(request.GET)
|
||||||
|
if form.is_valid():
|
||||||
|
return render(request, self.confirmation_template_name,
|
||||||
|
{'form': form})
|
||||||
|
else:
|
||||||
|
return HttpResponseBadRequest('invalid request')
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
form = KeyConfirmationForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
hash = _get_confirm_hash(form.cleaned_data['key'],
|
||||||
|
form.cleaned_data['email'])
|
||||||
|
if hash != form.cleaned_data['confirm_hash']:
|
||||||
|
return HttpResponseBadRequest('invalid request - bad hash')
|
||||||
|
|
||||||
|
# update the key
|
||||||
|
try:
|
||||||
|
key = Key.objects.get(key=form.cleaned_data['key'],
|
||||||
|
status__in=('u', 'a'))
|
||||||
|
except Key.DoesNotExist:
|
||||||
|
return HttpResponseBadRequest('invalid request - no key')
|
||||||
|
|
||||||
|
key.status = 'a'
|
||||||
|
key.save()
|
||||||
|
return render(request, self.confirmed_template_name, {'key': key})
|
||||||
|
else:
|
||||||
|
return HttpResponseBadRequest('invalid request - invalid form')
|
Loading…
Reference in New Issue
Block a user