commit 672d445a29cc955a3c97ce412bf95c54c7b13f34
Author: Sofia Papagiannaki <papagian@gmail.com>
Date:   Wed Apr 11 19:11:23 2012 +0300

    Ratelimit login attempts
    
    Refs: #2267

diff --git a/snf-astakos-app/README b/snf-astakos-app/README
index c67fb4c..996f8ce 100644
--- a/snf-astakos-app/README
+++ b/snf-astakos-app/README
@@ -40,9 +40,9 @@ Settings
 
 Configure in ``settings.py`` or a ``.conf`` file in ``/etc/synnefo`` if using snf-webproject.
 
-==============================      =============================================================================   ===========================================================================================
+=================================   =============================================================================   ===========================================================================================
 Name                                Default value                                                                   Description
-==============================      =============================================================================   ===========================================================================================
+=================================   =============================================================================   ===========================================================================================
 ASTAKOS_AUTH_TOKEN_DURATION         one month                                                                       Expiration time of newly created auth tokens
 ASTAKOS_DEFAULT_USER_LEVEL          4                                                                               Default (not-invited) user level
 ASTAKOS_INVITATIONS_PER_LEVEL       {0:100, 1:2, 2:0, 3:0, 4:0}                                                     Number of user invitations per user level
@@ -78,7 +78,10 @@ ASTAKOS_LOGIN_MESSAGES              {}
                                                                                                                     e.g. {'warning': 'Warning message (can contain html)'}
 ASTAKOS_PROFILE_EXTRA_LINKS         {}                                                                               messages to display as extra actions in account forms
                                                                                                                     e.g. {'https://cms.okeanos.grnet.gr/': 'Back to ~okeanos'}
-==============================      =============================================================================   ===========================================================================================
+ASTAKOS_RATELIMIT_RETRIES_ALLOWED   3                                                                               Number of unsuccessful login requests allowed for a specific account.
+                                                                                                                    When this number exceeds and ASTAKOS_RECAPTCHA_ENABLED is set the user has to solve a
+                                                                                                                    captcha challenge.
+=================================   =============================================================================   ===========================================================================================
 
 Administrator functions
 -----------------------
diff --git a/snf-astakos-app/astakos/im/activation_backends.py b/snf-astakos-app/astakos/im/activation_backends.py
index f576f8c..f167eba 100644
--- a/snf-astakos-app/astakos/im/activation_backends.py
+++ b/snf-astakos-app/astakos/im/activation_backends.py
@@ -208,9 +208,7 @@ class SimpleBackend(SignupBackend):
         if request.method == 'POST':
             if provider == request.POST.get('provider', ''):
                 initial_data = request.POST
-        ip = self.request.META.get('REMOTE_ADDR',
-                self.request.META.get('HTTP_X_REAL_IP', None))
-        return globals()[formclass](initial_data, instance=instance, ip=ip)
+        return globals()[formclass](initial_data, instance=instance, request=request)
     
     def _is_preaccepted(self, user):
         if super(SimpleBackend, self)._is_preaccepted(user):
diff --git a/snf-astakos-app/astakos/im/forms.py b/snf-astakos-app/astakos/im/forms.py
index e13964b..2964df1 100644
--- a/snf-astakos-app/astakos/im/forms.py
+++ b/snf-astakos-app/astakos/im/forms.py
@@ -76,9 +76,12 @@ class LocalUserCreationForm(UserCreationForm):
         """
         Changes the order of fields, and removes the username field.
         """
-        if 'ip' in kwargs:
-            self.ip = kwargs['ip']
-            kwargs.pop('ip')
+        request = kwargs.get('request', None)
+        if request:
+            kwargs.pop('request')
+            self.ip = request.META.get('REMOTE_ADDR',
+                                       request.META.get('HTTP_X_REAL_IP', None))
+        
         super(LocalUserCreationForm, self).__init__(*args, **kwargs)
         self.fields.keyOrder = ['email', 'first_name', 'last_name',
                                 'password1', 'password2']
@@ -186,8 +189,6 @@ class ThirdPartyUserCreationForm(forms.ModelForm):
         """
         Changes the order of fields, and removes the username field.
         """
-        if 'ip' in kwargs:
-            kwargs.pop('ip')
         super(ThirdPartyUserCreationForm, self).__init__(*args, **kwargs)
         self.fields.keyOrder = ['email', 'first_name', 'last_name',
                                 'provider', 'third_party_identifier']
@@ -284,6 +285,43 @@ class InvitedShibbolethUserCreationForm(InvitedThirdPartyUserCreationForm):
     
 class LoginForm(AuthenticationForm):
     username = forms.EmailField(label=_("Email"))
+    recaptcha_challenge_field = forms.CharField(widget=DummyWidget)
+    recaptcha_response_field = forms.CharField(widget=RecaptchaWidget, label='')
+    
+    def __init__(self, *args, **kwargs):
+        was_limited = kwargs.get('was_limited', False)
+        request = kwargs.get('request', None)
+        if request:
+            self.ip = request.META.get('REMOTE_ADDR',
+                                       request.META.get('HTTP_X_REAL_IP', None))
+        
+        t = ('request', 'was_limited')
+        for elem in t:
+            if elem in kwargs.keys():
+                kwargs.pop(elem)
+        super(LoginForm, self).__init__(*args, **kwargs)
+        
+        self.fields.keyOrder = ['username', 'password']
+        if was_limited and RECAPTCHA_ENABLED:
+            self.fields.keyOrder.extend(['recaptcha_challenge_field',
+                                         'recaptcha_response_field',])
+    
+    def clean_recaptcha_response_field(self):
+        if 'recaptcha_challenge_field' in self.cleaned_data:
+            self.validate_captcha()
+        return self.cleaned_data['recaptcha_response_field']
+
+    def clean_recaptcha_challenge_field(self):
+        if 'recaptcha_response_field' in self.cleaned_data:
+            self.validate_captcha()
+        return self.cleaned_data['recaptcha_challenge_field']
+
+    def validate_captcha(self):
+        rcf = self.cleaned_data['recaptcha_challenge_field']
+        rrf = self.cleaned_data['recaptcha_response_field']
+        check = captcha.submit(rcf, rrf, RECAPTCHA_PRIVATE_KEY, self.ip)
+        if not check.is_valid:
+            raise forms.ValidationError(_('You have not entered the correct words'))
 
 class ProfileForm(forms.ModelForm):
     """
diff --git a/snf-astakos-app/astakos/im/settings.py b/snf-astakos-app/astakos/im/settings.py
index 9e85386..ca664c2 100644
--- a/snf-astakos-app/astakos/im/settings.py
+++ b/snf-astakos-app/astakos/im/settings.py
@@ -80,3 +80,6 @@ LOGIN_MESSAGES = getattr(settings, 'ASTAKOS_LOGIN_MESSAGES', {})
 # e.g. {'https://cms.okeanos.grnet.gr/': 'Back to ~okeanos'}
 PROFILE_EXTRA_LINKS = getattr(settings, 'ASTAKOS_PROFILE_EXTRA_LINKS', {})
 
+# The number of unsuccessful login requests per minute allowed for a specific email
+RATELIMIT_RETRIES_ALLOWED = getattr(settings, 'ASTAKOS_RATELIMIT_RETRIES_ALLOWED', 3)
+
diff --git a/snf-astakos-app/astakos/im/target/local.py b/snf-astakos-app/astakos/im/target/local.py
index 2bbfaa4..0dce24c 100644
--- a/snf-astakos-app/astakos/im/target/local.py
+++ b/snf-astakos-app/astakos/im/target/local.py
@@ -42,13 +42,21 @@ from astakos.im.util import prepare_response
 from astakos.im.views import requires_anonymous
 from astakos.im.models import AstakosUser
 from astakos.im.forms import LoginForm
+from astakos.im.settings import RATELIMIT_RETRIES_ALLOWED
+
+from ratelimit.decorators import ratelimit
+
+retries = RATELIMIT_RETRIES_ALLOWED-1
+rate = str(retries)+'/m'
 
 @requires_anonymous
+@ratelimit(field='username', method='POST', rate=rate)
 def login(request, on_failure='im/login.html'):
     """
     on_failure: the template name to render on login failure
     """
-    form = LoginForm(data=request.POST)
+    was_limited = getattr(request, 'limited', False)
+    form = LoginForm(data=request.POST, was_limited=was_limited, request=request)
     next = request.POST.get('next')
     if not form.is_valid():
         return render_to_response(on_failure,
diff --git a/snf-astakos-app/astakos/im/target/shibboleth.py b/snf-astakos-app/astakos/im/target/shibboleth.py
index 2f90e3b..7854d29 100644
--- a/snf-astakos-app/astakos/im/target/shibboleth.py
+++ b/snf-astakos-app/astakos/im/target/shibboleth.py
@@ -84,7 +84,7 @@ def login(request,  backend=None, on_login_template='im/login.html', on_creation
             message = _('Inactive account')
             messages.add_message(request, messages.ERROR, message)
             return render_response(on_login_template,
-                                   login_form = LoginForm(),
+                                   login_form = LoginForm(request=request),
                                    context_instance=RequestContext(request))
     except AstakosUser.DoesNotExist, e:
         user = AstakosUser(third_party_identifier=eppn, realname=realname,
diff --git a/snf-astakos-app/astakos/im/views.py b/snf-astakos-app/astakos/im/views.py
index 2fbaf80..e511e4a 100644
--- a/snf-astakos-app/astakos/im/views.py
+++ b/snf-astakos-app/astakos/im/views.py
@@ -135,7 +135,7 @@ def index(request, login_template_name='im/login.html', profile_template_name='i
     if request.user.is_authenticated():
         return HttpResponseRedirect(reverse('astakos.im.views.edit_profile'))
     return render_response(template_name,
-                           login_form = LoginForm(),
+                           login_form = LoginForm(request=request),
                            context_instance = get_context(request, extra_context))
 
 @login_required
diff --git a/snf-astakos-app/setup.py b/snf-astakos-app/setup.py
index 93ccad8..acaaae2 100644
--- a/snf-astakos-app/setup.py
+++ b/snf-astakos-app/setup.py
@@ -79,7 +79,8 @@ INSTALL_REQUIRES = [
     'South>=0.7, <=0.7.3',
     'httplib2>=0.6.0',
     'snf-common>=0.9.0',
-    'recaptcha-client>=1.0.5'
+    'recaptcha-client>=1.0.5',
+    'django-ratelimit==0.1'
 ]
 
 EXTRAS_REQUIRES = {
