Browse Source

WIP new registration form

Rafał Pitoń 10 years ago
parent
commit
52a96b6ed6
34 changed files with 601 additions and 236 deletions
  1. 1 1
      misago/conf/defaults.py
  2. 0 111
      misago/core/captcha.py
  3. 0 76
      misago/core/migrations/0002_basic_settings.py
  4. 31 0
      misago/emberapp/app/components/form-row.js
  5. 8 0
      misago/emberapp/app/components/qacaptcha-field.js
  6. 4 0
      misago/emberapp/app/components/recaptcha-field.js
  7. 57 4
      misago/emberapp/app/components/register-form.js
  8. 21 0
      misago/emberapp/app/initializers/captcha-service.js
  9. 6 0
      misago/emberapp/app/models/captcha-question.js
  10. 16 0
      misago/emberapp/app/services/nocaptcha.js
  11. 22 0
      misago/emberapp/app/services/qacaptcha.js
  12. 16 0
      misago/emberapp/app/services/recaptcha.js
  13. 1 1
      misago/emberapp/app/services/zxcvbn.js
  14. 35 0
      misago/emberapp/app/styles/misago/form-panel.less
  15. 1 0
      misago/emberapp/app/templates/application.hbs
  16. 22 0
      misago/emberapp/app/templates/components/form-row.hbs
  17. 7 0
      misago/emberapp/app/templates/components/qacaptcha-field.hbs
  18. 1 0
      misago/emberapp/app/templates/components/recaptcha-field.hbs
  19. 70 5
      misago/emberapp/app/templates/components/register-form.hbs
  20. 1 1
      misago/emberapp/app/templates/error.hbs
  21. 0 1
      misago/emberapp/app/templates/register.hbs
  22. 6 1
      misago/emberapp/app/templates/register/form.hbs
  23. 1 1
      misago/emberapp/tests/acceptance/zxcvbn-service-test.js
  24. 0 1
      misago/project_template/requirements.txt
  25. 0 9
      misago/users/api/auth.py
  26. 23 0
      misago/users/api/captcha.py
  27. 27 0
      misago/users/api/users.py
  28. 86 0
      misago/users/captcha.py
  29. 3 16
      misago/users/forms/register.py
  30. 78 0
      misago/users/migrations/0002_users_settings.py
  31. 5 5
      misago/users/tests/test_auth_api.py
  32. 41 0
      misago/users/tests/test_captcha_api.py
  33. 11 2
      misago/users/urls/api.py
  34. 0 1
      misago/users/views/register.py

+ 1 - 1
misago/conf/defaults.py

@@ -252,7 +252,7 @@ MISAGO_STOP_FORUM_SPAM_MIN_CONFIDENCE = 80
 MISAGO_MAILER_BATCH_SIZE = 20
 MISAGO_MAILER_BATCH_SIZE = 20
 
 
 # Auth paths
 # Auth paths
-MISAGO_LOGIN_API_URL = 'auth/login'
+MISAGO_LOGIN_API_URL = 'login'
 
 
 LOGIN_REDIRECT_URL = 'misago:index'
 LOGIN_REDIRECT_URL = 'misago:index'
 LOGIN_URL = 'misago:login'
 LOGIN_URL = 'misago:login'

+ 0 - 111
misago/core/captcha.py

@@ -1,111 +0,0 @@
-from recaptcha.client.captcha import displayhtml, submit as submit_recaptcha
-from django.utils.translation import ugettext_lazy as _
-
-from misago.conf import settings
-from misago.core import forms
-
-
-def add_captcha_to_form(FormType, request):
-    captcha_type = settings.captcha_on_registration
-    test_passed = session_already_passed_test(request.session)
-
-    captcha_attrs = {}
-    if captcha_type == 'recaptcha':
-        captcha_attrs.update(add_recaptcha_to_form(request, test_passed))
-    elif captcha_type == 'qa':
-        captcha_attrs.update(add_qa_test_to_form(request, test_passed))
-
-    if captcha_attrs:
-        captcha_attrs['session'] = request.session
-
-    return type('FinalRegisterForm', (FormType,), captcha_attrs)
-
-
-"""
-reCaptcha
-"""
-def clean_recaptcha(self):
-    if not self.data.get('recaptcha_response_field'):
-        raise forms.ValidationError(_("This field is required."))
-
-    api_response = submit_recaptcha(
-        self.data.get('recaptcha_challenge_field'),
-        self.data.get('recaptcha_response_field'),
-        settings.recaptcha_private_api_key,
-        self._misago_real_ip)
-
-    if api_response.is_valid:
-        self.has_recaptcha = False
-        mark_session_as_passing(self.session)
-    else:
-        raise forms.ValidationError(_("Text from image is incorrect."))
-
-    return ''
-
-
-def add_recaptcha_to_form(request, test_passed):
-    recaptcha_field = forms.CharField(label=_('Security image'),
-                                      required=False)
-    field_html = displayhtml(settings.recaptcha_public_api_key,
-                             request.is_secure())
-
-    extra_fields = {
-        'passed_recaptcha': test_passed,
-        'has_recaptcha': True,
-        'recaptcha': recaptcha_field,
-        'recaptcha_html': field_html,
-        '_misago_real_ip': request._misago_real_ip,
-        'clean_recaptcha': clean_recaptcha,
-    }
-
-    if test_passed:
-        extra_fields['has_recaptcha'] = False
-        extra_fields.pop('clean_recaptcha')
-
-    return extra_fields
-
-
-"""
-Q&A
-"""
-def clean_qa_answer(self):
-    answer = self.cleaned_data['qa_answer'].lower()
-
-    for predefined_answer in settings.qa_answers.lower().splitlines():
-        predefined_answer = predefined_answer.strip().lower()
-        if answer == predefined_answer:
-            self.has_qa_captcha = False
-            mark_session_as_passing(self.session)
-            return self.cleaned_data['qa_answer']
-    else:
-        raise forms.ValidationError(_("Entered answer is incorrect."))
-
-
-def add_qa_test_to_form(request, test_passed):
-    qa_answer_field = forms.CharField(label=settings.qa_question,
-                                      help_text=settings.qa_help_text,
-                                      required=(not test_passed))
-
-    extra_fields = {
-        'passed_qa_captcha': test_passed,
-        'has_qa_captcha': True,
-        'qa_answer': qa_answer_field,
-        'clean_qa_answer': clean_qa_answer,
-    }
-
-    if test_passed:
-        extra_fields['has_qa_captcha'] = False
-        extra_fields.pop('clean_qa_answer')
-
-    return extra_fields
-
-
-"""
-Session utils
-"""
-def session_already_passed_test(session):
-    return session.get('passes_captcha')
-
-
-def mark_session_as_passing(session):
-    session['passes_captcha'] = True

+ 0 - 76
misago/core/migrations/0002_basic_settings.py

@@ -61,82 +61,6 @@ def create_basic_settings_group(apps, schema_editor):
             )
             )
         })
         })
 
 
-    migrate_settings_group(
-        apps,
-        {
-            'key': 'captcha',
-            'name': _("CAPTCHA"),
-            'description': _("Those settings allow you to combat automatic "
-                             "registrations and spam messages on your forum."),
-            'settings': (
-                {
-                    'setting': 'captcha_on_registration',
-                    'name': _("CAPTCHA on registration"),
-                    'legend': _("CAPTCHA types"),
-                    'value': 'no',
-                    'form_field': 'select',
-                    'field_extra': {
-                        'choices': (
-                            ('no', _("No protection")),
-                            ('recaptcha', _("reCaptcha")),
-                            ('qa', _("Question and answer")),
-                        ),
-                    },
-                },
-                {
-                    'setting': 'recaptcha_public_api_key',
-                    'name': _("Public API key"),
-                    'legend': _("reCAPTCHA"),
-                    'value': '',
-                    'field_extra': {
-                        'required': False,
-                        'max_length': 100,
-                    },
-                },
-                {
-                    'setting': 'recaptcha_private_api_key',
-                    'name': _("Private API key"),
-                    'value': '',
-                    'field_extra': {
-                        'required': False,
-                        'max_length': 100,
-                    },
-                },
-                {
-                    'setting': 'qa_question',
-                    'name': _("Test question"),
-                    'legend': _("Question and answer"),
-                    'value': '',
-                    'field_extra': {
-                        'required': False,
-                        'max_length': 250,
-                    },
-                },
-                {
-                    'setting': 'qa_help_text',
-                    'name': _("Question help text"),
-                    'value': '',
-                    'field_extra': {
-                        'required': False,
-                        'max_length': 250,
-                    },
-                },
-                {
-                    'setting': 'qa_answers',
-                    'name': _("Valid answers"),
-                    'description': _("Enter each answer in new line. "
-                                     "Answers are case-insensitive."),
-                    'value': '',
-                    'form_field': 'textarea',
-                    'field_extra': {
-                        'rows': 4,
-                        'required': False,
-                        'max_length': 250,
-                    },
-                },
-            )
-        })
-
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 

+ 31 - 0
misago/emberapp/app/components/form-row.js

@@ -0,0 +1,31 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+  classNames: 'form-group',
+  classNameBindings: [
+    'hasFeedback:has-feedback',
+    'hasSuccess:has-success',
+    'hasError:has-error'
+  ],
+
+  hasFeedback: function() {
+    return this.get('validation') !== null;
+  }.property('validation'),
+
+  hasSuccess: function() {
+    return this.get('hasFeedback') && !this.get('hasError');
+  }.property('hasFeedback', 'hasError'),
+
+  hasError: function() {
+    return this.get('hasFeedback') && this.get('errors');
+  }.property('hasFeedback', 'errors'),
+
+  errors: function() {
+    if (this.get('hasFeedback')) {
+      return this.get('validation.' + this.get('name')) || null;
+    }
+
+    return null;
+  }.property('validation', 'name')
+
+});

+ 8 - 0
misago/emberapp/app/components/qacaptcha-field.js

@@ -0,0 +1,8 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+  classNames: 'form-group',
+
+  captcha: Ember.inject.service('qacaptcha'),
+  model: Ember.computed.alias('captcha.model')
+});

+ 4 - 0
misago/emberapp/app/components/recaptcha-field.js

@@ -0,0 +1,4 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+});

+ 57 - 4
misago/emberapp/app/components/register-form.js

@@ -2,22 +2,75 @@ import Ember from 'ember';
 
 
 export default Ember.Component.extend({
 export default Ember.Component.extend({
   tagName: 'form',
   tagName: 'form',
+  classNames: 'form-horizontal',
 
 
   zxcvbn: Ember.inject.service('zxcvbn'),
   zxcvbn: Ember.inject.service('zxcvbn'),
 
 
   isReady: false,
   isReady: false,
+  isErrored: false,
   isLoading: false,
   isLoading: false,
+
+  username: '',
+  email: '',
   password: '',
   password: '',
+  captcha: Ember.computed.alias('captcha.value'),
+
+  validation: null,
 
 
-  loadZxcvb: function() {
+  loadServices: function() {
     var self = this;
     var self = this;
-    this.get('zxcvbn').loadLibrary().then(function() {
-      self.set('isReady', true);
-      console.log(self.get('zxcvbn').testPassword('pass1234'));
+
+    var promises = [
+      this.get('zxcvbn').load(),
+      this.get('captcha').load()
+    ];
+
+    Ember.RSVP.allSettled(promises).then(function(array) {
+      if (array[0].state === 'rejected') {
+        self.set('isErrored', true);
+        console.log('zxcvbn service failed to load.');
+      }
+
+      if (array[1].state === 'rejected') {
+        self.set('isErrored', true);
+        console.log('captcha service failed to load.');
+      }
+
+      self.set('isReady', !self.get('isErrored'));
     });
     });
   }.on('didInsertElement'),
   }.on('didInsertElement'),
 
 
   submit: function() {
   submit: function() {
+    if (this.get('isLoading')) {
+      return false;
+    }
+
+    this.set('isLoading', true);
+
+    var self = this;
+    this.rpc.ajax('users', {
+      username: this.get('username'),
+      email: this.get('email'),
+      password: this.get('password'),
+      captcha: this.get('captcha.value')
+    }).then(function(response) {
+      self.success(response);
+    }, function(jqXHR) {
+        self.error(jqXHR);
+    }).finally(function() {
+      self.set('isLoading', false);
+    })
+
     return false;
     return false;
+  },
+
+  success: function(response) {
+    console.log(response);
+  },
+
+  error: function(jqXHR) {
+    if (jqXHR.status === 400) {
+      this.set('validation', Ember.Object.create(jqXHR.responseJSON));
+    }
   }
   }
 });
 });

+ 21 - 0
misago/emberapp/app/initializers/captcha-service.js

@@ -0,0 +1,21 @@
+import NoCaptcha from 'misago/services/nocaptcha';
+import QACaptcha from 'misago/services/qacaptcha';
+import ReCaptcha from 'misago/services/recaptcha';
+
+export function initialize(container, application) {
+  application.register('service:nocaptcha', NoCaptcha, { singleton: true });
+  application.register('service:qacaptcha', QACaptcha, { singleton: true });
+  application.register('service:recaptcha', ReCaptcha, { singleton: true });
+
+  application.inject('service:recaptcha', 'settings', 'misago:settings');
+  application.inject('service:qacaptcha', 'store', 'store:main');
+
+  var captchaType = container.lookup('misago:settings').captcha_type;
+  application.inject('component', 'captcha', 'service:' + captchaType + 'captcha');
+}
+
+export default {
+  name: 'captcha-service',
+  after: 'misago-settings',
+  initialize: initialize
+};

+ 6 - 0
misago/emberapp/app/models/captcha-question.js

@@ -0,0 +1,6 @@
+import DS from 'ember-data';
+
+export default DS.Model.extend({
+  question: DS.attr('string'),
+  help_text: DS.attr('string')
+});

+ 16 - 0
misago/emberapp/app/services/nocaptcha.js

@@ -0,0 +1,16 @@
+import Ember from 'ember';
+
+export default Ember.Service.extend({
+  field: null,
+
+  load: function() {
+    // CAPTCHA is turned off, so don't load anything
+    return new Ember.RSVP.Promise(function(resolve) {
+      resolve();
+    });
+  },
+
+  value: function() {
+    return '';
+  }.property()
+});

+ 22 - 0
misago/emberapp/app/services/qacaptcha.js

@@ -0,0 +1,22 @@
+import Ember from 'ember';
+
+export default Ember.Service.extend({
+  field: 'qacaptcha-field',
+  model: null,
+
+  load: function() {
+    // Obtain QA question from API
+    var promise = this.store.find('captcha-question', 1);
+
+    var self = this;
+    promise.then(function(model) {
+      self.set('model', model);
+    });
+
+    return promise;
+  },
+
+  value: function() {
+    return Ember.$('#captcha-question').val() || '';
+  }.property().volatile()
+});

+ 16 - 0
misago/emberapp/app/services/recaptcha.js

@@ -0,0 +1,16 @@
+import Ember from 'ember';
+
+export default Ember.Service.extend({
+  field: 'recaptha-field',
+
+  load: function() {
+    // Load reCaptcha library from Google
+    return new Ember.RSVP.Promise(function(resolve) {
+      resolve();
+    });
+  },
+
+  value: function() {
+    return '';
+  }.property().volatile()
+});

+ 1 - 1
misago/emberapp/app/services/zxcvbn.js

@@ -10,7 +10,7 @@ export default Ember.Service.extend({
     return zxcvbn(password, inputs).score;
     return zxcvbn(password, inputs).score;
   },
   },
 
 
-  loadLibrary: function() {
+  load: function() {
     if (!this.get('includedJs')) {
     if (!this.get('includedJs')) {
       this._includeJs();
       this._includeJs();
     }
     }

+ 35 - 0
misago/emberapp/app/styles/misago/form-panel.less

@@ -6,6 +6,7 @@
 // Add custom variables
 // Add custom variables
 .panel-form {
 .panel-form {
   background: @panel-form-bg;
   background: @panel-form-bg;
+  border: none;
 
 
   .panel-variant(@panel-form-border;  @panel-form-heading-color; @panel-form-heading-bg; @panel-form-heading-border);
   .panel-variant(@panel-form-border;  @panel-form-heading-color; @panel-form-heading-bg; @panel-form-heading-border);
 
 
@@ -23,6 +24,40 @@
   }
   }
 }
 }
 
 
+// Loading/error states
+.panel-form {
+  .panel-loader {
+    .loader {
+      &:before {
+        background: @panel-form-bg;
+      }
+
+      &:after {
+        background: @panel-form-bg;
+      }
+    }
+  }
+
+  .panel-error {
+    padding: @line-height-computed 0px;
+
+    .error-icon {
+      padding-bottom: @line-height-computed;
+      .opacity(0.5);
+
+      font-size: @font-size-large * 4;
+    }
+
+    .error-message {
+      font-size: @font-size-large;
+      text-align: center;
+
+      p {
+        margin-bottom: 0px;
+      }
+    }
+  }
+}
 
 
 // Inputs well, useful if there's one input + button
 // Inputs well, useful if there's one input + button
 .well-form {
 .well-form {

+ 1 - 0
misago/emberapp/app/templates/application.hbs

@@ -7,3 +7,4 @@
 
 
 {{forum-footer}}
 {{forum-footer}}
 {{login-modal}}
 {{login-modal}}
+{{register-modal}}

+ 22 - 0
misago/emberapp/app/templates/components/form-row.hbs

@@ -0,0 +1,22 @@
+<label for="{{for}}" class="{{label-class}} control-label">{{label}}</label>
+<div class="{{control-class}}">
+  {{yield}}
+
+  {{#if hasFeedback}}
+    <span class="glyphicon {{if hasSuccess "glyphicon-ok" "glyphicon-remove"}} form-control-feedback" aria-hidden="true"></span>
+  {{/if}}
+
+  {{#if errors }}
+    <div class="help-block">
+      {{#each error in errors}}
+      <div>
+        <strong>{{error}}</strong>
+      </div>
+      {{/each}}
+    </div>
+  {{/if}}
+
+  {{#if help_text}}
+    <span class="help-block">{{help_text}}</span>
+  {{/if}}
+</div>

+ 7 - 0
misago/emberapp/app/templates/components/qacaptcha-field.hbs

@@ -0,0 +1,7 @@
+<label for="captcha-question" class="{{label-class}} control-label">{{model.question}}</label>
+<div class="{{control-class}}">
+  <input type="text" class="form-control" id="captcha-question">
+  {{#if model.help_text}}
+  <span class="help-block">{{model.help_text}}</span>
+  {{/if}}
+</div>

+ 1 - 0
misago/emberapp/app/templates/components/recaptcha-field.hbs

@@ -0,0 +1 @@
+I am reCaptcha input!

+ 70 - 5
misago/emberapp/app/templates/components/register-form.hbs

@@ -1,5 +1,70 @@
-{{#if isReady}}
-<p>{{gettext "Zxcvbn loaded!"}}</p>
-{{else}}
-<p>{{gettext "Preparing form..."}}</p>
-{{/if}}
+<div class="panel panel-form {{if isErrored "is-errored"}}">
+  {{#if isReady}}
+  <div class="panel-body">
+
+    {{#form-row label=(gettext "Username") for="id_username" label-class="col-md-4" control-class="col-md-8" validation=validation name="username"}}
+      {{input id="id_username" type="text" class="form-control" value=username}}
+    {{/form-row}}
+
+    {{#form-row label=(gettext "E-mail") for="id_email" label-class="col-md-4" control-class="col-md-8" validation=validation name="email"}}
+      {{input id="id_email" type="text" class="form-control" value=email}}
+    {{/form-row}}
+
+    {{#form-row label=(gettext "Password") for="id_password" label-class="col-md-4" control-class="col-md-8" validation=validation name="password"}}
+      {{input id="id_password" type="password" class="form-control" value=password}}
+    {{/form-row}}
+
+    {{#if captcha.field}}
+    {{component captcha.field label-class="col-md-4" control-class="col-md-8"}}
+    {{/if}}
+
+    <p class="lead">
+      !jak validacja kapci zakonczona, to wyswietlaj statyka &quot;hej, kapcze masz z glowy&quot;.
+      <br>
+      + podepnij feedback do kapci
+    </p>
+
+  </div>
+  <div class="panel-footer">
+    <div class="row">
+      <div class="col-md-8 col-md-offset-4">
+
+        {{#if isLoading}}
+        <button type="button" class="btn btn-primary" disabled="disabled">
+          <span class="fa fa-cog fa-spin"></span>
+          {{gettext "Registering..."}}
+        </button>
+        {{else}}
+        <button type="submit" class="btn btn-primary">
+          {{gettext "Register account"}}
+        </button>
+        {{/if}}
+
+        <div class="extra">
+          !tutaj krutkie pierdololo o tym ze rejestracja to akceptacja regulaminu
+        </div>
+
+      </div>
+    </div>
+
+  </div>
+  {{else if isErrored}}
+  <div class="panel-body panel-error text-center">
+    <div class="error">
+
+      <div class="error-icon">
+        <span class="fa fa-warning"></span>
+      </div>
+
+      <div class="error-message">
+        <p>{{gettext "This form is not avaialable at the moment."}}</p>
+      </div>
+
+    </div>
+  </div>
+  {{else}}
+  <div class="panel-body panel-loader">
+    <div class="loader"></div>
+  </div>
+  {{/if}}
+</div>

+ 1 - 1
misago/emberapp/app/templates/error.hbs

@@ -3,7 +3,7 @@
     <div class="error">
     <div class="error">
 
 
       <div class="error-icon">
       <div class="error-icon">
-        <span class="fa fa-fire"></span>
+        <span class="fa fa-warning"></span>
       </div>
       </div>
 
 
       <div class="error-message">
       <div class="error-message">

+ 0 - 1
misago/emberapp/app/templates/register.hbs

@@ -1 +0,0 @@
-{{outlet}}

+ 6 - 1
misago/emberapp/app/templates/register/form.hbs

@@ -6,8 +6,13 @@
   </div>
   </div>
 
 
   <div class="container">
   <div class="container">
+    <div class="row">
+      <div class="col-md-8 col-md-offset-2">
 
 
-    {{register-form}}
+        {{register-form}}
+
+      </div>
+    </div>
 
 
   </div>
   </div>
 </div>
 </div>

+ 1 - 1
misago/emberapp/tests/acceptance/zxcvbn-service-test.js

@@ -21,7 +21,7 @@ test('loading zxcvbn and testing password with it', function(assert) {
   assert.expect(2);
   assert.expect(2);
 
 
   Ember.run(function() {
   Ember.run(function() {
-    service.loadLibrary().then(function() {
+    service.load().then(function() {
       assert.ok(typeof zxcvbn !== 'undefined');
       assert.ok(typeof zxcvbn !== 'undefined');
       assert.ok(service.scorePassword('L0r3m !p5um') > 0);
       assert.ok(service.scorePassword('L0r3m !p5um') > 0);
         done();
         done();

+ 0 - 1
misago/project_template/requirements.txt

@@ -15,6 +15,5 @@ path.py==7.0
 pillow==2.7.0
 pillow==2.7.0
 psycopg2==2.5.4
 psycopg2==2.5.4
 pytz
 pytz
-recaptcha-client==1.0.6
 requests==2.5.1
 requests==2.5.1
 unidecode
 unidecode

+ 0 - 9
misago/users/api/auth.py

@@ -26,12 +26,3 @@ def login(request):
     else:
     else:
         return Response(form.get_errors_dict(),
         return Response(form.get_errors_dict(),
                         status=status.HTTP_400_BAD_REQUEST)
                         status=status.HTTP_400_BAD_REQUEST)
-
-
-@api_view(['GET', 'POST'])
-def user(request):
-    if request.user.is_authenticated():
-        return Response(AuthenticatedUserSerializer(request.user).data)
-    else:
-        return Response({'id': None})
-

+ 23 - 0
misago/users/api/captcha.py

@@ -0,0 +1,23 @@
+from django.http import Http404
+
+from rest_framework.decorators import api_view
+from rest_framework.response import Response
+
+from misago.conf import settings
+
+
+@api_view(['GET', 'POST'])
+def question(request, question_id):
+    try:
+        question_id = int(question_id)
+    except TypeError:
+        raise Http404()
+
+    if settings.qa_question and question_id == 1:
+        return Response({
+            'id': question_id,
+            'question': settings.qa_question,
+            'help_text': settings.qa_help_text,
+        })
+    else:
+        raise Http404()

+ 27 - 0
misago/users/api/users.py

@@ -0,0 +1,27 @@
+from django.contrib.auth import get_user_model
+
+from rest_framework import status, viewsets
+from rest_framework.response import Response
+
+from misago.users.forms.register import RegisterForm
+
+
+class UserViewSet(viewsets.ViewSet):
+    """
+    API endpoint for users manipulation
+    """
+    queryset = get_user_model().objects.all()
+
+    def list(self, request):
+        pass
+
+    def create(self, request):
+        """
+        POST to /api/users is treated as new user registration
+        """
+        form = RegisterForm(request.data)
+        if form.is_valid():
+            return Response({'detail': 'Wolololo!'})
+        else:
+            return Response(form.errors,
+                            status=status.HTTP_400_BAD_REQUEST)

+ 86 - 0
misago/users/captcha.py

@@ -0,0 +1,86 @@
+from recaptcha.client.captcha import displayhtml, submit as submit_recaptcha
+from django.utils.translation import ugettext_lazy as _
+
+from misago.conf import settings
+from misago.core import forms
+
+
+def validate_recaptcha(request):
+    raise NotImplementedError('reCaptcha 2 is not implemented')
+    answer = self.cleaned_data['qa_answer'].lower()
+
+    for predefined_answer in settings.qa_answers.lower().splitlines():
+        predefined_answer = predefined_answer.strip().lower()
+        if answer == predefined_answer:
+            self.has_qa_captcha = False
+            return self.cleaned_data['qa_answer']
+    else:
+        raise forms.ValidationError(_("Entered answer is incorrect."))
+
+
+def validate_qacaptcha(request):
+    raise NotImplementedError('Q&A captcha is not implemented')
+
+
+def validate_nocaptcha(request):
+    return # no captcha means no validation
+
+
+CAPTCHA_TESTS = {
+    're': validate_recaptcha,
+    'qa': validate_qacaptcha,
+    'no': validate_nocaptcha,
+}
+
+def validate_captcha(request):
+    if not session_already_passed_test(request.session):
+        # run test and if it didn't raise validation error,
+        # mark session as passing so we don't troll uses anymore
+        CAPTCHA_TESTS[settings['captcha_type']](request)
+        mark_session_as_passing(request.session)
+
+
+"""
+Q&A
+"""
+def clean_qa_answer(self):
+    answer = self.cleaned_data['qa_answer'].lower()
+
+    for predefined_answer in settings.qa_answers.lower().splitlines():
+        predefined_answer = predefined_answer.strip().lower()
+        if answer == predefined_answer:
+            self.has_qa_captcha = False
+            mark_session_as_passing(self.session)
+            return self.cleaned_data['qa_answer']
+    else:
+        raise forms.ValidationError(_("Entered answer is incorrect."))
+
+
+def add_qa_test_to_form(request, test_passed):
+    qa_answer_field = forms.CharField(label=settings.qa_question,
+                                      help_text=settings.qa_help_text,
+                                      required=(not test_passed))
+
+    extra_fields = {
+        'passed_qa_captcha': test_passed,
+        'has_qa_captcha': True,
+        'qa_answer': qa_answer_field,
+        'clean_qa_answer': clean_qa_answer,
+    }
+
+    if test_passed:
+        extra_fields['has_qa_captcha'] = False
+        extra_fields.pop('clean_qa_answer')
+
+    return extra_fields
+
+
+"""
+Session utils
+"""
+def session_already_passed_test(session):
+    return session.get('passed_captcha')
+
+
+def mark_session_as_passing(session):
+    session['passed_captcha'] = True

+ 3 - 16
misago/users/forms/register.py

@@ -1,22 +1,9 @@
-from django.utils.translation import ugettext_lazy as _
 from misago.core import forms
 from misago.core import forms
 from misago.users import validators
 from misago.users import validators
 
 
 
 
 class RegisterForm(forms.Form):
 class RegisterForm(forms.Form):
-    username = forms.CharField(label=_("Username"),
-                               validators=[validators.validate_username])
-    email = forms.CharField(label=_("Email"),
-                            validators=[validators.validate_email])
-    password = forms.CharField(label=_("Password"),
-                               validators=[validators.validate_password],
+    username = forms.CharField(validators=[validators.validate_username])
+    email = forms.CharField(validators=[validators.validate_email])
+    password = forms.CharField(validators=[validators.validate_password],
                                widget=forms.PasswordInput(render_value=True))
                                widget=forms.PasswordInput(render_value=True))
-
-    def __init__(self, *args, **kwargs):
-        super(RegisterForm, self).__init__(*args, **kwargs)
-
-        self.api_fields = (
-            (self['username'], 'misago:api_validate_username'),
-            (self['email'], 'misago:api_validate_email'),
-            (self['password'], 'misago:api_validate_password'),
-        )

+ 78 - 0
misago/users/migrations/0002_users_settings.py

@@ -176,6 +176,84 @@ def create_users_settings_group(apps, schema_editor):
             )
             )
         })
         })
 
 
+    migrate_settings_group(
+        apps,
+        {
+            'key': 'captcha',
+            'name': _("CAPTCHA"),
+            'description': _("Those settings allow you to combat automatic "
+                             "registrations on your forum."),
+            'settings': (
+                {
+                    'setting': 'captcha_type',
+                    'name': _("Select CAPTCHA type"),
+                    'legend': _("CAPTCHA type"),
+                    'value': 'no',
+                    'form_field': 'select',
+                    'field_extra': {
+                        'choices': (
+                            ('no', _("No CAPTCHA")),
+                            ('re', _("reCaptcha")),
+                            ('qa', _("Question and answer")),
+                        ),
+                    },
+                    'is_public': True,
+                },
+                {
+                    'setting': 'recaptcha_site_key',
+                    'name': _("Site key"),
+                    'legend': _("reCAPTCHA"),
+                    'value': '',
+                    'field_extra': {
+                        'required': False,
+                        'max_length': 100,
+                    },
+                    'is_public': True,
+                },
+                {
+                    'setting': 'recaptcha_secret_key',
+                    'name': _("Secret key"),
+                    'value': '',
+                    'field_extra': {
+                        'required': False,
+                        'max_length': 100,
+                    },
+                },
+                {
+                    'setting': 'qa_question',
+                    'name': _("Test question"),
+                    'legend': _("Question and answer"),
+                    'value': '',
+                    'field_extra': {
+                        'required': False,
+                        'max_length': 250,
+                    },
+                },
+                {
+                    'setting': 'qa_help_text',
+                    'name': _("Question help text"),
+                    'value': '',
+                    'field_extra': {
+                        'required': False,
+                        'max_length': 250,
+                    },
+                },
+                {
+                    'setting': 'qa_answers',
+                    'name': _("Valid answers"),
+                    'description': _("Enter each answer in new line. "
+                                     "Answers are case-insensitive."),
+                    'value': '',
+                    'form_field': 'textarea',
+                    'field_extra': {
+                        'rows': 4,
+                        'required': False,
+                        'max_length': 250,
+                    },
+                },
+            )
+        })
+
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 

+ 5 - 5
misago/users/tests/test_auth_api.py

@@ -7,7 +7,7 @@ from django.test import TestCase
 from misago.users.models import Ban, BAN_USERNAME
 from misago.users.models import Ban, BAN_USERNAME
 
 
 
 
-class AuthenticateAPITests(TestCase):
+class AuthenticationAPITests(TestCase):
     def test_api_invalid_credentials(self):
     def test_api_invalid_credentials(self):
         """login api returns 400 on invalid POST"""
         """login api returns 400 on invalid POST"""
         response = self.client.post(
         response = self.client.post(
@@ -23,7 +23,7 @@ class AuthenticateAPITests(TestCase):
         user_json = json.loads(response.content)
         user_json = json.loads(response.content)
         self.assertIsNone(user_json['id'])
         self.assertIsNone(user_json['id'])
 
 
-    def test_api_signin(self):
+    def test_api_login(self):
         """api signs user in"""
         """api signs user in"""
         User = get_user_model()
         User = get_user_model()
         user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
         user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
@@ -41,7 +41,7 @@ class AuthenticateAPITests(TestCase):
         self.assertEqual(user_json['id'], user.id)
         self.assertEqual(user_json['id'], user.id)
         self.assertEqual(user_json['username'], user.username)
         self.assertEqual(user_json['username'], user.username)
 
 
-    def test_api_signin_banned(self):
+    def test_api_login_banned(self):
         """login api fails to sign banned user in"""
         """login api fails to sign banned user in"""
         User = get_user_model()
         User = get_user_model()
         User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
         User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
@@ -62,7 +62,7 @@ class AuthenticateAPITests(TestCase):
         self.assertEqual(response_json['detail']['message']['html'],
         self.assertEqual(response_json['detail']['message']['html'],
                          '<p>%s</p>' % ban.user_message)
                          '<p>%s</p>' % ban.user_message)
 
 
-    def test_api_signin_inactive_admin(self):
+    def test_api_login_inactive_admin(self):
         """login api fails to sign admin-activated user in"""
         """login api fails to sign admin-activated user in"""
         User = get_user_model()
         User = get_user_model()
         User.objects.create_user('Bob', 'bob@test.com', 'Pass.123',
         User.objects.create_user('Bob', 'bob@test.com', 'Pass.123',
@@ -76,7 +76,7 @@ class AuthenticateAPITests(TestCase):
         response_json = json.loads(response.content)
         response_json = json.loads(response.content)
         self.assertEqual(response_json['code'], 'inactive_user')
         self.assertEqual(response_json['code'], 'inactive_user')
 
 
-    def test_api_signin_inactive_user(self):
+    def test_api_login_inactive_user(self):
         """login api fails to sign user-activated user in"""
         """login api fails to sign user-activated user in"""
         User = get_user_model()
         User = get_user_model()
         User.objects.create_user('Bob', 'bob@test.com', 'Pass.123',
         User.objects.create_user('Bob', 'bob@test.com', 'Pass.123',

+ 41 - 0
misago/users/tests/test_captcha_api.py

@@ -0,0 +1,41 @@
+import json
+
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+
+from misago.conf import settings
+
+class AuthenticateAPITests(TestCase):
+    def setUp(self):
+        self.api_link = reverse('misago:api:captcha_question',
+                                kwargs={'question_id': 1})
+
+    def tearDown(self):
+        settings.reset_settings()
+
+    def test_api_no_qa_is_set(self):
+        """qa api returns 404 if no QA question is set"""
+        settings.override_setting('qa_question', '')
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 404)
+
+    def test_api_invalid_qa_id(self):
+        """qa api returns QA question only for ID #1"""
+        settings.override_setting('qa_question', 'Do you like pies?')
+
+        response = self.client.get(self.api_link.replace('1', '24'))
+        self.assertEqual(response.status_code, 404)
+
+    def test_api_get_question_id(self):
+        """qa api returns valid QA question for ID #1"""
+        settings.override_setting('qa_question', 'Do you like pies?')
+        settings.override_setting('qa_help_text', 'Type in "yes".')
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json['id'], 1)
+        self.assertEqual(response_json['question'], 'Do you like pies?')
+        self.assertEqual(response_json['help_text'], 'Type in "yes".')

+ 11 - 2
misago/users/urls/api.py

@@ -1,9 +1,10 @@
 from django.conf.urls import patterns, url
 from django.conf.urls import patterns, url
+from rest_framework.routers import DefaultRouter
+from misago.users.api.users import UserViewSet
 
 
 
 
 urlpatterns = patterns('misago.users.api.auth',
 urlpatterns = patterns('misago.users.api.auth',
-    url(r'^auth/login/$', 'login', name='login'),
-    url(r'^auth/$', 'user', name='auth_user'),
+    url(r'^login/$', 'login', name='login'),
 )
 )
 
 
 urlpatterns += patterns('misago.users.api.activation',
 urlpatterns += patterns('misago.users.api.activation',
@@ -16,3 +17,11 @@ urlpatterns += patterns('misago.users.api.changepassword',
     url(r'^change-password/(?P<user_id>\d+)/(?P<token>[a-zA-Z0-9]+)/validate-token/$', 'validate_token', name='change_password_validate_token'),
     url(r'^change-password/(?P<user_id>\d+)/(?P<token>[a-zA-Z0-9]+)/validate-token/$', 'validate_token', name='change_password_validate_token'),
     url(r'^change-password/(?P<user_id>\d+)/(?P<token>[a-zA-Z0-9]+)/$', 'change_password', name='change_password'),
     url(r'^change-password/(?P<user_id>\d+)/(?P<token>[a-zA-Z0-9]+)/$', 'change_password', name='change_password'),
 )
 )
+
+urlpatterns = patterns('misago.users.api.captcha',
+    url(r'^captcha-questions/(?P<question_id>\d+)/$', 'question', name='captcha_question'),
+)
+
+router = DefaultRouter()
+router.register(r'users', UserViewSet)
+urlpatterns += router.urls

+ 0 - 1
misago/users/views/register.py

@@ -10,7 +10,6 @@ from django.views.decorators.cache import never_cache
 from django.views.decorators.debug import sensitive_post_parameters
 from django.views.decorators.debug import sensitive_post_parameters
 
 
 from misago.conf import settings
 from misago.conf import settings
-from misago.core.captcha import add_captcha_to_form
 from misago.core.mail import mail_user
 from misago.core.mail import mail_user
 
 
 from misago.users.bans import ban_ip
 from misago.users.bans import ban_ip