Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Storages #103

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions captcha/conf/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
CAPTCHA_OUTPUT_FORMAT = getattr(settings, 'CAPTCHA_OUTPUT_FORMAT', None)

CAPTCHA_TEST_MODE = getattr(settings, 'CAPTCHA_TEST_MODE', getattr(settings, 'CATPCHA_TEST_MODE', False))
CAPTCHA_STORAGE = getattr(settings, 'CAPTCHA_STORAGE', {
'BACKEND': 'captcha.storages.db.DBStorage',
})

# Failsafe
if CAPTCHA_DICTIONARY_MIN_LENGTH > CAPTCHA_DICTIONARY_MAX_LENGTH:
Expand Down
18 changes: 9 additions & 9 deletions captcha/fields.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from captcha.conf import settings
from captcha.models import CaptchaStore, get_safe_now
from django.core.exceptions import ImproperlyConfigured
from captcha.storages import storage
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
from django.core.urlresolvers import reverse, NoReverseMatch
from django.forms import ValidationError
from django.forms.fields import CharField, MultiValueField
Expand Down Expand Up @@ -29,15 +29,15 @@ def decompress(self, value):

def fetch_captcha_store(self, name, value, attrs=None):
"""
Fetches a new CaptchaStore
Fetches a new captcha record
This has to be called inside render
"""
try:
reverse('captcha-image', args=('dummy',))
except NoReverseMatch:
raise ImproperlyConfigured('Make sure you\'ve included captcha.urls as explained in the INSTALLATION section on http://readthedocs.org/docs/django-simple-captcha/en/latest/usage.html#installation')

key = CaptchaStore.generate_key()
key = storage.generate_key()

# these can be used by format_output and render
self._value = [key, u('')]
Expand Down Expand Up @@ -154,20 +154,20 @@ def compress(self, data_list):
def clean(self, value):
super(CaptchaField, self).clean(value)
response, value[1] = (value[1] or '').strip().lower(), ''
CaptchaStore.remove_expired()
storage.remove_expired()
if settings.CAPTCHA_TEST_MODE and response.lower() == 'passed':
# automatically pass the test
try:
# try to delete the captcha based on its hash
CaptchaStore.objects.get(hashkey=value[0]).delete()
except CaptchaStore.DoesNotExist:
storage.delete(hashkey=value[0])
except ObjectDoesNotExist:
# ignore errors
pass
elif not self.required and not response:
pass
else:
try:
CaptchaStore.objects.get(response=response, hashkey=value[0], expiration__gt=get_safe_now()).delete()
except CaptchaStore.DoesNotExist:
storage.delete_wanted(hashkey=value[0], response=response)
except ObjectDoesNotExist:
raise ValidationError(getattr(self, 'error_messages', {}).get('invalid', ugettext_lazy('Invalid CAPTCHA')))
return value
18 changes: 16 additions & 2 deletions captcha/helpers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
# -*- coding: utf-8 -*-
import random
from captcha.conf import settings
from django.core.urlresolvers import reverse
import datetime
from six import u, text_type

from django.core.urlresolvers import reverse
from django.conf import settings as dj_settings

from captcha.conf import settings


def get_safe_now():
try:
from django.utils.timezone import utc
if dj_settings.USE_TZ:
return datetime.datetime.utcnow().replace(tzinfo=utc)
except:
pass
return datetime.datetime.now()


def math_challenge():
operators = ('+', '*', '-',)
Expand Down
11 changes: 6 additions & 5 deletions captcha/management/commands/captcha_clean.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
from django.core.management.base import BaseCommand
from captcha.models import get_safe_now
import sys

from django.core.management.base import BaseCommand

from captcha.storages import storage


class Command(BaseCommand):
help = "Clean up expired captcha hashkeys."

def handle(self, **options):
from captcha.models import CaptchaStore
verbose = int(options.get('verbosity'))
expired_keys = CaptchaStore.objects.filter(expiration__lte=get_safe_now()).count()
expired_keys = storage.get_count_of_expired()
if verbose >= 1:
print("Currently %d expired hashkeys" % expired_keys)
try:
CaptchaStore.remove_expired()
storage.remove_expired()
except:
if verbose >= 1:
print("Unable to delete expired hashkeys.")
Expand Down
52 changes: 0 additions & 52 deletions captcha/models.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,4 @@
from captcha.conf import settings as captcha_settings
from django.db import models
from django.conf import settings
from django.utils.encoding import smart_text
import datetime
import random
import time
import hashlib


# Heavily based on session key generation in Django
# Use the system (hardware-based) random number generator if it exists.
if hasattr(random, 'SystemRandom'):
randrange = random.SystemRandom().randrange
else:
randrange = random.randrange
MAX_RANDOM_KEY = 18446744073709551616 # 2 << 63


def get_safe_now():
try:
from django.utils.timezone import utc
if settings.USE_TZ:
return datetime.datetime.utcnow().replace(tzinfo=utc)
except:
pass
return datetime.datetime.now()


class CaptchaStore(models.Model):
Expand All @@ -33,31 +7,5 @@ class CaptchaStore(models.Model):
hashkey = models.CharField(blank=False, max_length=40, unique=True)
expiration = models.DateTimeField(blank=False)

def save(self, *args, **kwargs):
self.response = self.response.lower()
if not self.expiration:
self.expiration = get_safe_now() + datetime.timedelta(minutes=int(captcha_settings.CAPTCHA_TIMEOUT))
if not self.hashkey:
key_ = (
smart_text(randrange(0, MAX_RANDOM_KEY)) +
smart_text(time.time()) +
smart_text(self.challenge, errors='ignore') +
smart_text(self.response, errors='ignore')
).encode('utf8')
self.hashkey = hashlib.sha1(key_).hexdigest()
del(key_)
super(CaptchaStore, self).save(*args, **kwargs)

def __unicode__(self):
return self.challenge

def remove_expired(cls):
cls.objects.filter(expiration__lte=get_safe_now()).delete()
remove_expired = classmethod(remove_expired)

@classmethod
def generate_key(cls):
challenge, response = captcha_settings.get_challenge()()
store = cls.objects.create(challenge=challenge, response=response)

return store.hashkey
27 changes: 27 additions & 0 deletions captcha/storages/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string

from ..conf import settings as captcha_settings


class InvalidStorageBackendError(ImproperlyConfigured):
pass


def get_storage(storage_conf=None):
conf = storage_conf or captcha_settings.CAPTCHA_STORAGE
try:
backend = conf['BACKEND']
# Trying to import the given backend, in case it's a dotted path
backend_cls = import_string(backend)
except (KeyError, ImportError) as e:
raise InvalidStorageBackendError("Could not find storage backend '%s': %s" % (
backend, e))
try:
params = conf['PARAMS'].copy()
except KeyError:
params = {}
return backend_cls(params)


storage = get_storage()
73 changes: 73 additions & 0 deletions captcha/storages/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import datetime
import random
import time
import hashlib

from django.utils.encoding import smart_text

from ..models import CaptchaStore
from ..helpers import get_safe_now
from ..conf import settings as captcha_settings


# Heavily based on session key generation in Django
# Use the system (hardware-based) random number generator if it exists.
if hasattr(random, 'SystemRandom'):
randrange = random.SystemRandom().randrange
else:
randrange = random.randrange
MAX_RANDOM_KEY = 18446744073709551616 # 2 << 63


class BaseStorage(object):
model_class = CaptchaStore
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that model_class should be set in the DB Storage and not in the base storage. I see that it is used to raise DoesNotExist exception but probably will be better if there is a custom exception which will be common for all storages.


def __init__(self, params):
self.params = params

def create_obj(self, challenge, response, hashkey, expiration):
raise NotImplemented("Override this method in %s" % self.__class__.__name__)

def delete(self, hashkey, obj=None):
raise NotImplemented("Override this method in %s" % self.__class__.__name__)

def create(self, challenge, response, hashkey=None, expiration=None):
response = response.lower()
hashkey = hashkey or self.get_hashkey(challenge, response)
expiration = expiration or self.get_expiration()
return self.create_obj(challenge, response, hashkey, expiration)

def get(self, hashkey):
raise NotImplemented("Override this method in %s" % self.__class__.__name__)

def delete_wanted(self, hashkey, response, date_to_compare=None):
date_to_compare = date_to_compare or get_safe_now()
obj = self.get(hashkey)
if obj.response != response or obj.expiration <= date_to_compare:
raise self.model_class.DoesNotExist
self.delete(hashkey, obj=obj)

def get_hashkey(self, challenge, response):
key_ = (
smart_text(randrange(0, MAX_RANDOM_KEY)) +
smart_text(time.time()) +
smart_text(challenge, errors='ignore') +
smart_text(response, errors='ignore')
).encode('utf8')
return hashlib.sha1(key_).hexdigest()

def get_expiration(self):
return get_safe_now() + datetime.timedelta(minutes=int(captcha_settings.CAPTCHA_TIMEOUT))

def remove_expired(self):
raise NotImplemented("Override this method in %s" % self.__class__.__name__)

def get_count_of_expired(self):
raise NotImplemented("Override this method in %s" % self.__class__.__name__)

def generate_key(self):
challenge, response = captcha_settings.get_challenge()()
hashkey = self.get_hashkey(challenge, response)
self.create(challenge=challenge, response=response, hashkey=hashkey)

return hashkey
54 changes: 54 additions & 0 deletions captcha/storages/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from django.core.cache import caches
from django.utils.encoding import force_text

from ..helpers import get_safe_now
from .base import BaseStorage


class CacheStorage(BaseStorage):
key_pattern = "captcha_storage_cache_%s"

def __init__(self, params):
super(CacheStorage, self).__init__(params)
alias = params.get('ALIAS', 'default')
self.cache = caches[alias]

def get_key(self, hashkey):
return self.key_pattern % hashkey

def create_obj(self, challenge, response, hashkey, expiration):
key = self.get_key(hashkey)
data = dict(
challenge=force_text(challenge),
response=force_text(response),
hashkey=force_text(hashkey),
expiration=expiration
)
self.cache.set(key, data, timeout=self.get_timeout())
return self.model_class(**data)

def delete(self, hashkey, obj=None):
key = self.get_key(hashkey)
self.cache.delete(key)

def get(self, hashkey):
key = self.get_key(hashkey)
data = self.cache.get(key)
if not data:
raise self.model_class.DoesNotExist
return self.model_class(**data)

def get_timeout(self):
return (self.get_expiration() - get_safe_now()).total_seconds()

def remove_expired(self):
"""
In cache keys expired automatically
"""
pass

def get_count_of_expired(self):
"""
undefined for cache
"""
return None
30 changes: 30 additions & 0 deletions captcha/storages/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from ..helpers import get_safe_now
from .base import BaseStorage


class DBStorage(BaseStorage):

def create_obj(self, challenge, response, hashkey, expiration):
return self.model_class.objects.create(
challenge=challenge,
response=response,
hashkey=hashkey,
expiration=expiration
)

def delete(self, hashkey, obj=None):
if not obj:
obj = self.get(hashkey)
obj.delete()

def get(self, hashkey):
return self.model_class.objects.get(hashkey=hashkey)

def expired_qs(self):
return self.model_class.objects.filter(expiration__lte=get_safe_now())

def remove_expired(self):
self.expired_qs().delete()

def get_count_of_expired(self):
return self.expired_qs().count()
Loading