Permalink
Browse files

add in pin locking (bug 826813)

  • Loading branch information...
1 parent 7f5221e commit 6337caf3d582d83f917aa23c5f92cf1736fbd031 @andymckay andymckay committed Jan 7, 2013
View
@@ -44,6 +44,7 @@ class BuyerForm(ParanoidModelForm, PinMixin):
class Meta:
model = Buyer
+ exclude = ['pin_locked_out', 'pin_failures']
class PinForm(ParanoidForm, PinMixin):
View
@@ -1,3 +1,6 @@
+from datetime import datetime
+
+from django.conf import settings
from django.db import models
from aesfield.field import AESField
@@ -10,13 +13,39 @@ class Buyer(Model):
uuid = models.CharField(max_length=255, db_index=True, unique=True)
pin = HashField(blank=True, null=True)
pin_confirmed = models.BooleanField(default=False)
+ pin_failures = models.IntegerField(default=0)
+ pin_locked_out = models.DateTimeField(blank=True, null=True)
active = models.BooleanField(default=True, db_index=True)
new_pin = HashField(blank=True, null=True)
needs_pin_reset = models.BooleanField(default=False)
class Meta(Model.Meta):
db_table = 'buyer'
+ @property
+ def locked_out(self):
+ return bool(self.pin_locked_out)
+
+ def clear_lockout(self):
+ self.pin_failures = 0
+ self.pin_locked_out = None
+ self.save()
+
+ def incr_lockout(self):
+ # Use F to avoid race conditions, although this means an extra
+ # query to check if we've gone over the limit.
+ self.pin_failures = models.F('pin_failures') + 1
+ self.save()
+
+ failing = self.reget()
+ if failing.pin_failures >= settings.PIN_FAILURES:
+ failing.pin_locked_out = datetime.now()
+ failing.save()
+ # Indicate to the caller that we are now locked out.
+ return True
+
+ return False
+
class BuyerPaypal(Model):
key = AESField(blank=True, null=True, aes_key='buyerpaypal:key')
@@ -1,5 +1,8 @@
-from solitude.base import get_object_or_404, ModelResource, Resource
+from django.http import HttpResponseForbidden
+
+from solitude.base import get_object_or_404, log_cef, ModelResource, Resource
from tastypie import fields
+from tastypie.exceptions import ImmediateHttpResponse
from tastypie.validation import FormValidation
from .forms import BuyerForm, BuyerFormValidation, PinForm
@@ -10,6 +13,9 @@ class BuyerResource(ModelResource):
paypal = fields.ToOneField('lib.buyers.resources.BuyerPaypalResource',
'paypal', blank=True, full=True,
null=True, readonly=True)
+ pin_failures = fields.IntegerField(attribute='pin_failures', readonly=True)
+ pin_locked_out = fields.DateTimeField(attribute='pin_locked_out',
+ blank=True, null=True, readonly=True)
class Meta(ModelResource.Meta):
queryset = Buyer.objects.filter()
@@ -92,7 +98,17 @@ class Meta(BuyerEndpointBase.Meta):
def obj_create(self, bundle, request=None, **kwargs):
buyer = self.get_data(bundle)
if buyer.pin_confirmed:
+ if buyer.locked_out:
+ log_cef('Attempted access to locked out account: %s'
+ % buyer.uuid, request, severity=1)
+ raise ImmediateHttpResponse(response=HttpResponseForbidden())
+
bundle.obj.valid = buyer.pin == bundle.data.pop('pin')
+ if not bundle.obj.valid:
+ locked = buyer.incr_lockout()
+ if locked:
+ log_cef('Locked out account: %s' % buyer.uuid,
+ request, severity=1)
else:
bundle.obj.valid = False
return bundle
@@ -1,5 +1,8 @@
+from datetime import datetime
import json
+from django.conf import settings
+
from django_paranoia.signals import warning
import mock
from nose.tools import eq_
@@ -62,9 +65,28 @@ def test_get(self):
obj = self.create()
res = self.client.get(self.get_detail_url('buyer', obj))
eq_(res.status_code, 200)
- eq_(json.loads(res.content)['uuid'], self.uuid)
+ data = json.loads(res.content)
+ eq_(data['uuid'], self.uuid)
+ eq_(data['pin'], True)
+ eq_(data['pin_failures'], 0)
+ eq_(data['pin_locked_out'], None)
+
+ @mock.patch.object(settings, 'PIN_FAILURES', 1)
+ def test_locked_out(self):
+ obj = self.create()
+ obj.incr_lockout()
+ res = self.client.get(self.get_detail_url('buyer', obj))
+ eq_(res.status_code, 200)
data = json.loads(res.content)
eq_(data['pin'], True)
+ eq_(data['pin_failures'], 1)
+ assert data['pin_locked_out'] is not None
+
+ def test_not_patch_failures(self):
+ obj = self.create()
+ self.client.patch(self.get_detail_url('buyer', obj),
+ data={'pin_failures': 5, 'pin': '1234'})
+ eq_(obj.reget().pin_failures, 0)
def test_not_active(self):
obj = self.create()
@@ -237,6 +259,29 @@ def test_good_uuid_and_bad_pin(self):
assert not data['valid']
eq_(data['uuid'], self.uuid)
+ def test_failure_counted(self):
+ self.client.post(self.list_url, data={'uuid': self.uuid,
+ 'pin': self.pin[::-1]})
+ eq_(self.buyer.reget().pin_failures, 1)
+
+ @mock.patch.object(settings, 'PIN_FAILURES', 1)
+ @mock.patch('solitude.base.log_cef')
+ def test_locked_out(self, log_cef):
+ self.client.post(self.list_url, data={'uuid': self.uuid,
+ 'pin': self.pin[::-1]})
+ assert self.buyer.reget().locked_out
+ assert log_cef.called
+
+ @mock.patch('solitude.base.log_cef')
+ def test_good_pin_but_locked_out(self, log_cef):
+ self.buyer.pin_locked_out = datetime.now()
+ self.buyer.save()
+
+ res = self.client.post(self.list_url, data={'uuid': self.uuid,
+ 'pin': self.pin})
+ assert log_cef.called
+ eq_(res.status_code, 403)
+
def test_good_uuid_and_good_pin_and_bad_confirmed(self):
self.buyer.pin_confirmed = False
self.buyer.save()
@@ -1,6 +1,9 @@
+from datetime import datetime
+
from aesfield.field import EncryptedField
from nose.tools import eq_
+from django.conf import settings
from django.test import TestCase
from lib.buyers.models import Buyer, BuyerPaypal
@@ -37,3 +40,30 @@ def test_set_empty(self):
def test_filter(self):
with self.assertRaises(EncryptedField):
BuyerPaypal.objects.filter(key='bar')
+
+ def test_locked_out(self):
+ assert not self.buyer.locked_out
+ self.buyer.pin_locked_out = datetime.now()
+ self.buyer.save()
+ assert self.buyer.reget().locked_out
+
+ def test_increment(self):
+ for x in range(1, settings.PIN_FAILURES + 1):
+ res = self.buyer.incr_lockout()
+ buyer = self.buyer.reget()
+ eq_(buyer.pin_failures, x)
+
+ # On the last pass, we should be locked out.
+ if x == settings.PIN_FAILURES:
+ assert res
+ assert buyer.pin_locked_out
+ else:
+ assert not res
+ assert not buyer.pin_locked_out
+
+ def test_clear(self):
+ self.buyer.pin_failues = 1
+ self.buyer.pin_locked_out = datetime.now()
+ self.buyer.clear_lockout()
+ eq_(self.buyer.pin_failures, 0)
+ eq_(self.buyer.pin_locked_out, None)
@@ -0,0 +1,2 @@
+ALTER TABLE `buyer` ADD COLUMN `pin_failures` integer NOT NULL;
+ALTER TABLE `buyer` ADD COLUMN `pin_locked_out` datetime;
@@ -186,3 +186,6 @@
'django_paranoia.reporters.log',
'django_paranoia.reporters.cef_'
]
+
+# The number of PIN failures before we lock them out.
+PIN_FAILURES = 5

0 comments on commit 6337caf

Please sign in to comment.