From e22aff51a4d4192857ebf0fac7463be4441ad0cd Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Thu, 10 Jan 2013 07:57:44 -0800 Subject: [PATCH 01/33] Clarify user model/register form interaction in docs. --- docs/customizing.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/customizing.rst b/docs/customizing.rst index 8bc5601..d2c6018 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -75,6 +75,18 @@ register form or override validators:: security = Security(app, user_datastore, register_form=ExtendedRegisterForm) +For the ``register_form`` and ``confirm_register_form``, each field is +passed to the user model when a user is created. In the above case, +the ``first_name`` and ``last_name`` fields are passed directly to the +model, so the model should look like:: + + class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(255)) + first_name = db.Column(db.String(255)) + last_name = db.Column(db.String(255)) + The following is a list of all the available form overrides: * ``login_form``: Login form From 4dd944a8c11c1a6dcfc00483eaa29202c66d0dff Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Fri, 11 Jan 2013 18:38:05 -0800 Subject: [PATCH 02/33] Another fix to docs --- docs/customizing.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/customizing.rst b/docs/customizing.rst index d2c6018..e4e4836 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -76,9 +76,9 @@ register form or override validators:: register_form=ExtendedRegisterForm) For the ``register_form`` and ``confirm_register_form``, each field is -passed to the user model when a user is created. In the above case, -the ``first_name`` and ``last_name`` fields are passed directly to the -model, so the model should look like:: +passed to the user model (as kwargs) when a user is created. In the +above case, the ``first_name`` and ``last_name`` fields are passed +directly to the model, so the model should look like:: class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) From c5c27768f21366a21797dbf3640c42f19973a560 Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Fri, 11 Jan 2013 19:07:07 -0800 Subject: [PATCH 03/33] First pieces of change password form --- flask_security/core.py | 10 ++++++++-- flask_security/forms.py | 10 ++++++++++ tests/configured_tests.py | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 60d6530..b413c74 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -22,8 +22,8 @@ from werkzeug.local import LocalProxy from .utils import config_value as cv, get_config, md5, url_for_security from .views import create_blueprint from .forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ - ForgotPasswordForm, ResetPasswordForm, SendConfirmationForm, \ - PasswordlessLoginForm + ForgotPasswordForm, ChangePasswordForm, ResetPasswordForm, \ + SendConfirmationForm, PasswordlessLoginForm # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) @@ -40,6 +40,7 @@ _default_config = { 'LOGOUT_URL': '/logout', 'REGISTER_URL': '/register', 'RESET_URL': '/reset', + 'CHANGE_URL': '/change', 'CONFIRM_URL': '/confirm', 'POST_LOGIN_VIEW': '/', 'POST_LOGOUT_VIEW': '/', @@ -47,12 +48,14 @@ _default_config = { 'POST_REGISTER_VIEW': None, 'POST_CONFIRM_VIEW': None, 'POST_RESET_VIEW': None, + 'POST_CHANGE_VIEW': None, 'UNAUTHORIZED_VIEW': None, 'CONFIRMABLE': False, 'REGISTERABLE': False, 'RECOVERABLE': False, 'TRACKABLE': False, 'PASSWORDLESS': False, + 'CHANGEABLE': False, 'LOGIN_WITHIN': '1 days', 'CONFIRM_EMAIL_WITHIN': '5 days', 'RESET_PASSWORD_WITHIN': '5 days', @@ -63,6 +66,7 @@ _default_config = { 'CONFIRM_SALT': 'confirm-salt', 'RESET_SALT': 'reset-salt', 'LOGIN_SALT': 'login-salt', + 'CHANGE_SALT': 'change-salt', 'REMEMBER_SALT': 'remember-salt', 'DEFAULT_HTTP_AUTH_REALM': 'Login Required', 'EMAIL_SUBJECT_REGISTER': 'Welcome', @@ -93,6 +97,7 @@ _default_messages = { 'DISABLED_ACCOUNT': ('Account is disabled.', 'error'), 'PASSWORDLESS_LOGIN_SUCCESSFUL': ('You have successfuly logged in.', 'success'), 'PASSWORD_RESET': ('You successfully reset your password and you have been logged in automatically.', 'success'), + 'PASSWORD_CHANGE': ('You successfully changes your password.', 'success'), 'LOGIN': ('Please log in to access this page.', 'info'), 'REFRESH': ('Please reauthenticate to access this page.', 'info') } @@ -114,6 +119,7 @@ _default_forms = { 'register_form': RegisterForm, 'forgot_password_form': ForgotPasswordForm, 'reset_password_form': ResetPasswordForm, + 'change_password_form': ChangePasswordForm, 'send_confirmation_form': SendConfirmationForm, 'passwordless_login_form': PasswordlessLoginForm, } diff --git a/flask_security/forms.py b/flask_security/forms.py index 64ae022..9234322 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -195,3 +195,13 @@ class ResetPasswordForm(Form, NewPasswordFormMixin, PasswordConfirmFormMixin): """The default reset password form""" submit = SubmitField("Reset Password") + + +class ChangePasswordForm(Form, PasswordFormMixin, PasswordConfirmFormMixin): + """The default Change password form""" + + new_password = PasswordField("New Password", + validators=[password_required, + Length(min=6, max=128)]) + + submit = SubmitField("Change Password") diff --git a/tests/configured_tests.py b/tests/configured_tests.py index f1e0d7d..fc00353 100644 --- a/tests/configured_tests.py +++ b/tests/configured_tests.py @@ -318,6 +318,24 @@ class ExpiredResetPasswordTest(SecurityTest): self.assertIn('You did not reset your password within', r.data) +class ChangePasswordTest(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_CHANGEABLE': True, + 'SECURITY_POST_CHANGE_VIEW': '/', + } + + def test_change_password(self): + self.authenticate() + r = self.client.post('/change/' + t, data={ + 'password': 'password', + 'new_password': 'newpassword', + 'new_password_confirm': 'newpassword' + }, follow_redirects=True) + + self.assertIn('You successfully changed your password', r.data) + + class TrackableTests(SecurityTest): AUTH_CONFIG = { From 9a47ec1ed966d8c67dfd5b504b131ae161573958 Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Fri, 11 Jan 2013 22:35:54 -0800 Subject: [PATCH 04/33] Working on change password form --- flask_security/core.py | 11 +++++-- flask_security/forms.py | 25 +++++++++++++--- flask_security/templates/security/_menu.html | 5 +++- flask_security/views.py | 31 ++++++++++++++++++++ tests/configured_tests.py | 27 +++++++++++++++-- 5 files changed, 89 insertions(+), 10 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index b413c74..6ae5ba4 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -97,7 +97,8 @@ _default_messages = { 'DISABLED_ACCOUNT': ('Account is disabled.', 'error'), 'PASSWORDLESS_LOGIN_SUCCESSFUL': ('You have successfuly logged in.', 'success'), 'PASSWORD_RESET': ('You successfully reset your password and you have been logged in automatically.', 'success'), - 'PASSWORD_CHANGE': ('You successfully changes your password.', 'success'), + 'PASSWORD_CHANGE': ('You successfully changed your password.', 'success'), + 'INVALID_PASSWORD': ('Invalid password', 'error'), 'LOGIN': ('Please log in to access this page.', 'info'), 'REFRESH': ('Please reauthenticate to access this page.', 'info') } @@ -294,6 +295,9 @@ class _SecurityState(object): def reset_password_context_processor(self, fn): self._add_ctx_processor('reset_password', fn) + def change_password_context_processor(self, fn): + self._add_ctx_processor('change_password', fn) + def send_confirmation_context_processor(self, fn): self._add_ctx_processor('send_confirmation', fn) @@ -323,8 +327,8 @@ class Security(object): def init_app(self, app, datastore=None, register_blueprint=True, login_form=None, confirm_register_form=None, register_form=None, forgot_password_form=None, - reset_password_form=None, send_confirmation_form=None, - passwordless_login_form=None): + reset_password_form=None, change_password_form=None, + send_confirmation_form=None, passwordless_login_form=None): """Initializes the Flask-Security extension for the specified application and datastore implentation. @@ -348,6 +352,7 @@ class Security(object): register_form=register_form, forgot_password_form=forgot_password_form, reset_password_form=reset_password_form, + change_password_form=change_password_form, send_confirmation_form=send_confirmation_form, passwordless_login_form=passwordless_login_form) diff --git a/flask_security/forms.py b/flask_security/forms.py index 9234322..4b2808b 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -15,10 +15,11 @@ from flask import request, current_app from flask.ext.wtf import Form as BaseForm, TextField, PasswordField, \ SubmitField, HiddenField, Required, BooleanField, EqualTo, Email, \ ValidationError, Length, Field +from flask.ext.login import current_user from werkzeug.local import LocalProxy from .confirmable import requires_confirmation -from .utils import verify_and_update_password, get_message +from .utils import verify_password, verify_and_update_password, get_message # Convenient reference _datastore = LocalProxy(lambda: current_app.extensions['security'].datastore) @@ -83,6 +84,7 @@ class NewPasswordFormMixin(): validators=[password_required, Length(min=6, max=128)]) + class PasswordConfirmFormMixin(): password_confirm = PasswordField("Retype Password", validators=[EqualTo('password', message="Passwords do not match")]) @@ -97,7 +99,8 @@ class RegisterFormMixin(): def to_dict(form): def is_field_and_user_attr(member): - return isinstance(member, Field) and hasattr(_datastore.user_model, member.name) + return isinstance(member, Field) and \ + hasattr(_datastore.user_model, member.name) fields = inspect.getmembers(form, is_field_and_user_attr) return dict((key, value.data) for key, value in fields) @@ -172,7 +175,7 @@ class LoginForm(Form, NextFormMixin): self.email.errors.append('Specified user does not exist') return False if not verify_and_update_password(self.password.data, self.user): - self.password.errors.append('Invalid password') + self.password.errors.append(get_message('INVALID_PASSWORD')[0]) return False if requires_confirmation(self.user): self.email.errors.append(get_message('CONFIRMATION_REQUIRED')[0]) @@ -197,11 +200,25 @@ class ResetPasswordForm(Form, NewPasswordFormMixin, PasswordConfirmFormMixin): submit = SubmitField("Reset Password") -class ChangePasswordForm(Form, PasswordFormMixin, PasswordConfirmFormMixin): +class ChangePasswordForm(Form, PasswordFormMixin): """The default Change password form""" new_password = PasswordField("New Password", validators=[password_required, Length(min=6, max=128)]) + new_password_confirm = PasswordField("Retype Password", + validators=[EqualTo('new_password', message="Passwords do not match")]) + submit = SubmitField("Change Password") + + def validate(self): + super(ChangePasswordForm, self).validate() + + if self.password.data.strip() == '': + self.password.errors.append('Password not provided') + return False + if not verify_and_update_password(self.password.data, current_user): + self.password.errors.append(get_message('INVALID_PASSWORD')[0]) + return False + return True diff --git a/flask_security/templates/security/_menu.html b/flask_security/templates/security/_menu.html index d50b5a4..272cd22 100644 --- a/flask_security/templates/security/_menu.html +++ b/flask_security/templates/security/_menu.html @@ -5,6 +5,9 @@ {% if security.registerable %}
  • Register
  • {% endif %} + {% if security.changeable %} +
  • Change password
  • + {% endif %} {% if security.recoverable %}
  • Forgot password
  • {% endif %} @@ -12,4 +15,4 @@
  • Confirm account
  • {% endif %} -{% endif %} \ No newline at end of file +{% endif %} diff --git a/flask_security/views.py b/flask_security/views.py index 3efd316..6a810f5 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -278,6 +278,32 @@ def reset_password(token): **_ctx('reset_password')) +@login_required +def change_password(): + """View function which handles a change password request.""" + + form_class = _security.change_password_form + + if request.json: + form = form_class(MultiDict(request.json)) + else: + form = form_class() + + if form.validate_on_submit(): + #send_reset_password_instructions(form.user) + print 'VALID' + if request.json is None: + #do_flash(*get_message('PASSWORD_CHANGE')) + do_flash('dicks') + + if request.json: + return _render_json(form) + + return render_template('security/change_password.html', + change_password_form=form, + **_ctx('change_password')) + + def create_blueprint(state, import_name): """Creates the security extension blueprint""" @@ -311,6 +337,11 @@ def create_blueprint(state, import_name): methods=['GET', 'POST'], endpoint='reset_password')(reset_password) + if state.changeable: + bp.route(state.change_url, + methods=['GET', 'POST'], + endpoint='change_password')(change_password) + if state.confirmable: bp.route(state.confirm_url, methods=['GET', 'POST'], diff --git a/tests/configured_tests.py b/tests/configured_tests.py index fc00353..9d874b9 100644 --- a/tests/configured_tests.py +++ b/tests/configured_tests.py @@ -327,12 +327,35 @@ class ChangePasswordTest(SecurityTest): def test_change_password(self): self.authenticate() - r = self.client.post('/change/' + t, data={ + r = self.client.get('/change', follow_redirects=True) + self.assertIn('Change password', r.data) + + def test_change_password_invalid(self): + self.authenticate() + r = self.client.post('/change', data={ + 'password': 'notpassword', + 'new_password': 'newpassword', + 'new_password_confirm': 'newpassword' + }, follow_redirects=True) + print r + self.assertIn('Invalid password', r.data) + + def test_change_password_mismatch(self): + self.authenticate() + r = self.client.post('/change', data={ + 'password': 'password', + 'new_password': 'newpassword', + 'new_password_confirm': 'notnewpassword' + }, follow_redirects=True) + self.assertIn('Passwords do not match', r.data) + + def test_change_password_success(self): + self.authenticate() + r = self.client.post('/change', data={ 'password': 'password', 'new_password': 'newpassword', 'new_password_confirm': 'newpassword' }, follow_redirects=True) - self.assertIn('You successfully changed your password', r.data) From b67e61d62544a688d58a5506610f300634a5f0ba Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Sat, 12 Jan 2013 14:40:42 -0800 Subject: [PATCH 05/33] Change password form --- flask_security/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index 6a810f5..2a375eb 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -11,6 +11,7 @@ from flask import current_app, redirect, request, render_template, jsonify, \ after_this_request, Blueprint +from flask.ext.login import current_user from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy @@ -290,11 +291,10 @@ def change_password(): form = form_class() if form.validate_on_submit(): - #send_reset_password_instructions(form.user) - print 'VALID' + after_this_request(_commit) + update_password(current_user, form.new_password.data) if request.json is None: - #do_flash(*get_message('PASSWORD_CHANGE')) - do_flash('dicks') + do_flash(*get_message('PASSWORD_CHANGE')) if request.json: return _render_json(form) From 050ccb847ac40de99cd0ba3f04a599ad82a66a87 Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Sat, 12 Jan 2013 14:55:30 -0800 Subject: [PATCH 06/33] Forgot to add form --- .gitignore | 6 ++++++ .../templates/security/change_password.html | 11 +++++++++++ 2 files changed, 17 insertions(+) create mode 100644 flask_security/templates/security/change_password.html diff --git a/.gitignore b/.gitignore index 23d1773..fcf114d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,9 @@ pip-log.txt #Mr Developer .mr.developer.cfg + +#Virtualenv +env/ + +#Editor temporaries +*~ diff --git a/flask_security/templates/security/change_password.html b/flask_security/templates/security/change_password.html new file mode 100644 index 0000000..5b656bf --- /dev/null +++ b/flask_security/templates/security/change_password.html @@ -0,0 +1,11 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

    Change password

    +
    + {{ change_password_form.hidden_tag() }} + {{ render_field_with_errors(change_password_form.password) }} + {{ render_field_with_errors(change_password_form.new_password) }} + {{ render_field_with_errors(change_password_form.new_password_confirm) }} + {{ render_field(change_password_form.submit) }} +
    +{% include "security/_menu.html" %} From 508f4d1b526a627571e1a72358e41ad3e4e62ae7 Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Sat, 12 Jan 2013 15:57:52 -0800 Subject: [PATCH 07/33] Fix change password form --- flask_security/forms.py | 7 ++++--- flask_security/templates/security/_menu.html | 3 --- flask_security/templates/security/change_password.html | 3 ++- tests/configured_tests.py | 3 ++- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/flask_security/forms.py b/flask_security/forms.py index 4b2808b..48f7b82 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -200,8 +200,8 @@ class ResetPasswordForm(Form, NewPasswordFormMixin, PasswordConfirmFormMixin): submit = SubmitField("Reset Password") -class ChangePasswordForm(Form, PasswordFormMixin): - """The default Change password form""" +class ChangePasswordForm(Form, NextFormMixin, PasswordFormMixin): + """The default change password form""" new_password = PasswordField("New Password", validators=[password_required, @@ -213,7 +213,8 @@ class ChangePasswordForm(Form, PasswordFormMixin): submit = SubmitField("Change Password") def validate(self): - super(ChangePasswordForm, self).validate() + if not super(ChangePasswordForm, self).validate(): + return False if self.password.data.strip() == '': self.password.errors.append('Password not provided') diff --git a/flask_security/templates/security/_menu.html b/flask_security/templates/security/_menu.html index 272cd22..5291f80 100644 --- a/flask_security/templates/security/_menu.html +++ b/flask_security/templates/security/_menu.html @@ -5,9 +5,6 @@ {% if security.registerable %}
  • Register
  • {% endif %} - {% if security.changeable %} -
  • Change password
  • - {% endif %} {% if security.recoverable %}
  • Forgot password
  • {% endif %} diff --git a/flask_security/templates/security/change_password.html b/flask_security/templates/security/change_password.html index 5b656bf..02701c4 100644 --- a/flask_security/templates/security/change_password.html +++ b/flask_security/templates/security/change_password.html @@ -6,6 +6,7 @@ {{ render_field_with_errors(change_password_form.password) }} {{ render_field_with_errors(change_password_form.new_password) }} {{ render_field_with_errors(change_password_form.new_password_confirm) }} + {{ render_field(change_password_form.next) }} {{ render_field(change_password_form.submit) }} -{% include "security/_menu.html" %} + diff --git a/tests/configured_tests.py b/tests/configured_tests.py index 9d874b9..c01e3dd 100644 --- a/tests/configured_tests.py +++ b/tests/configured_tests.py @@ -337,7 +337,7 @@ class ChangePasswordTest(SecurityTest): 'new_password': 'newpassword', 'new_password_confirm': 'newpassword' }, follow_redirects=True) - print r + self.assertNotIn('You successfully changed your password', r.data) self.assertIn('Invalid password', r.data) def test_change_password_mismatch(self): @@ -347,6 +347,7 @@ class ChangePasswordTest(SecurityTest): 'new_password': 'newpassword', 'new_password_confirm': 'notnewpassword' }, follow_redirects=True) + self.assertNotIn('You successfully changed your password', r.data) self.assertIn('Passwords do not match', r.data) def test_change_password_success(self): From ded62a556b8751fc5cf4fffd5d370a5e0500cbfd Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Sat, 12 Jan 2013 19:03:02 -0800 Subject: [PATCH 08/33] Add a password-changed signal --- docs/api.rst | 11 +++- docs/customizing.rst | 5 ++ flask_security/changeable.py | 45 ++++++++++++++++ flask_security/core.py | 1 + flask_security/signals.py | 2 + .../security/email/change_notice.html | 4 ++ .../security/email/change_notice.txt | 5 ++ flask_security/utils.py | 5 +- flask_security/views.py | 5 +- tests/configured_tests.py | 10 ++++ tests/signals_tests.py | 54 ++++++++++++++++++- 11 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 flask_security/changeable.py create mode 100644 flask_security/templates/security/email/change_notice.html create mode 100644 flask_security/templates/security/email/change_notice.txt diff --git a/docs/api.rst b/docs/api.rst index 4b86c60..48b5163 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -83,7 +83,13 @@ sends the following signals. .. data:: password_reset - Sent when a user completes a password. It is passed the `user`. + Sent when a user completes a password reset. It is passed the + `user`. + +.. data:: password_changed + + Sent when a user completes a password change. It is passed the + `user`. .. data:: reset_password_instructions_sent @@ -91,6 +97,7 @@ sends the following signals. with the `user` and `token`, the user being logged in and the (if so configured) the reset token issued. -All signals are also passed a `app` keyword argument, which is the current application. +All signals are also passed a `app` keyword argument, which is the +current application. .. _Flask documentation on signals: http://flask.pocoo.org/docs/signals/ diff --git a/docs/customizing.rst b/docs/customizing.rst index 8bc5601..7d3de3e 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -18,6 +18,7 @@ following is a list of view templates: * `security/login_user.html` * `security/register_user.html` * `security/reset_password.html` +* `security/change_password.html` * `security/send_confirmation.html` * `security/send_login.html` @@ -55,6 +56,7 @@ The following is a list of all the available context processor decorators: * ``login_context_processor``: Login view * ``register_context_processor``: Register view * ``reset_password_context_processor``: Reset password view +* ``change_password_context_processor``: Reset password view * ``send_confirmation_context_processor``: Send confirmation view * ``send_login_context_processor``: Send login view @@ -82,6 +84,7 @@ The following is a list of all the available form overrides: * ``register_form``: Register form * ``forgot_password_form``: Forgot password form * ``reset_password_form``: Reset password form +* ``change_password_form``: Reset password form * ``send_confirmation_form``: Send confirmation form * ``passwordless_login_form``: Passwordless login form @@ -100,6 +103,8 @@ The following is a list of email templates: * `security/mail/reset_instructions.html` * `security/mail/reset_instructions.txt` * `security/mail/reset_notice.html` +* `security/mail/change_notice.txt` +* `security/mail/change_notice.html` * `security/mail/reset_notice.txt` * `security/mail/welcome.html` * `security/mail/welcome.txt` diff --git a/flask_security/changeable.py b/flask_security/changeable.py new file mode 100644 index 0000000..4447b65 --- /dev/null +++ b/flask_security/changeable.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.changeable + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security recoverable module + + :copyright: (c) 2012 by Matt Wright. + :author: Eskil Heyn Olsen + :license: MIT, see LICENSE for more details. +""" + +from flask import current_app as app, request +from werkzeug.local import LocalProxy + +from .signals import password_changed +from .utils import send_mail, encrypt_password, url_for_security, \ + config_value + + +# Convenient references +_security = LocalProxy(lambda: app.extensions['security']) + +_datastore = LocalProxy(lambda: _security.datastore) + + +def send_password_changed_notice(user): + """Sends the password changed notice email for the specified user. + + :param user: The user to send the notice to + """ + send_mail(config_value('EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE'), user.email, + 'change_notice', user=user) + + +def change_user_password(user, password): + """Change the specified user's password + + :param user: The user to change_password + :param password: The unencrypted new password + """ + user.password = encrypt_password(password) + _datastore.put(user) + send_password_changed_notice(user) + password_changed.send(user, app=app._get_current_object()) diff --git a/flask_security/core.py b/flask_security/core.py index 6ae5ba4..afb88f7 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -73,6 +73,7 @@ _default_config = { 'EMAIL_SUBJECT_CONFIRM': 'Please confirm your email', 'EMAIL_SUBJECT_PASSWORDLESS': 'Login instructions', 'EMAIL_SUBJECT_PASSWORD_NOTICE': 'Your password has been reset', + 'EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE': 'Your password has been changed', 'EMAIL_SUBJECT_PASSWORD_RESET': 'Password reset instructions' } diff --git a/flask_security/signals.py b/flask_security/signals.py index 17aac64..e1c2954 100644 --- a/flask_security/signals.py +++ b/flask_security/signals.py @@ -24,4 +24,6 @@ login_instructions_sent = signals.signal("login-instructions-sent") password_reset = signals.signal("password-reset") +password_changed = signals.signal("password-changed") + reset_password_instructions_sent = signals.signal("password-reset-instructions-sent") diff --git a/flask_security/templates/security/email/change_notice.html b/flask_security/templates/security/email/change_notice.html new file mode 100644 index 0000000..8280683 --- /dev/null +++ b/flask_security/templates/security/email/change_notice.html @@ -0,0 +1,4 @@ +

    Your password has been changed.

    +{% if security.recoverable %} +

    If you did not change your password, click here to reset your password.

    +{% endif %} diff --git a/flask_security/templates/security/email/change_notice.txt b/flask_security/templates/security/email/change_notice.txt new file mode 100644 index 0000000..df2d2d4 --- /dev/null +++ b/flask_security/templates/security/email/change_notice.txt @@ -0,0 +1,5 @@ +Your password has been changed +{% if security.recoverable %} +If you did not change your password, click the link below to reset your password: +{{ url_for_security('forgot_password') }} +{% endif %} diff --git a/flask_security/utils.py b/flask_security/utils.py index 547e231..fe83978 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -27,7 +27,7 @@ from werkzeug.local import LocalProxy from .signals import user_registered, user_confirmed, \ confirm_instructions_sent, login_instructions_sent, \ - password_reset, reset_password_instructions_sent + password_reset, password_changed, reset_password_instructions_sent # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) @@ -372,6 +372,7 @@ def capture_signals(): """Factory method that creates a `CaptureSignals` with all the flask_security signals.""" return CaptureSignals([user_registered, user_confirmed, confirm_instructions_sent, login_instructions_sent, - password_reset, reset_password_instructions_sent]) + password_reset, password_changed, + reset_password_instructions_sent]) diff --git a/flask_security/views.py b/flask_security/views.py index 2a375eb..587a686 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -11,7 +11,7 @@ from flask import current_app, redirect, request, render_template, jsonify, \ after_this_request, Blueprint -from flask.ext.login import current_user +from flask_login import current_user from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy @@ -22,6 +22,7 @@ from .passwordless import send_login_instructions, \ login_token_status from .recoverable import reset_password_token_status, \ send_reset_password_instructions, update_password +from .changeable import change_user_password from .registerable import register_user from .utils import get_url, get_post_login_redirect, do_flash, \ get_message, login_user, logout_user, url_for_security as url_for @@ -292,7 +293,7 @@ def change_password(): if form.validate_on_submit(): after_this_request(_commit) - update_password(current_user, form.new_password.data) + change_user_password(current_user, form.new_password.data) if request.json is None: do_flash(*get_message('PASSWORD_CHANGE')) diff --git a/tests/configured_tests.py b/tests/configured_tests.py index c01e3dd..af248d7 100644 --- a/tests/configured_tests.py +++ b/tests/configured_tests.py @@ -350,6 +350,16 @@ class ChangePasswordTest(SecurityTest): self.assertNotIn('You successfully changed your password', r.data) self.assertIn('Passwords do not match', r.data) + def test_change_password_bad_password(self): + self.authenticate() + r = self.client.post('/change', data={ + 'password': 'password', + 'new_password': 'a', + 'new_password_confirm': 'a' + }, follow_redirects=True) + self.assertNotIn('You successfully changed your password', r.data) + self.assertIn('Field must be between', r.data) + def test_change_password_success(self): self.authenticate() r = self.client.post('/change', data={ diff --git a/tests/signals_tests.py b/tests/signals_tests.py index 51fde0f..8ca2a56 100644 --- a/tests/signals_tests.py +++ b/tests/signals_tests.py @@ -3,7 +3,8 @@ from __future__ import with_statement from flask_security.utils import (capture_registrations, capture_reset_password_requests, capture_signals) from flask_security.signals import (user_registered, user_confirmed, confirm_instructions_sent, login_instructions_sent, - password_reset, reset_password_instructions_sent) + password_reset, password_changed, + reset_password_instructions_sent) from tests import SecurityTest @@ -158,6 +159,57 @@ class RecoverableSignalsTests(SecurityTest): self.assertEqual(mocks.signals_sent(), set()) +class ChangeableSignalsTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_CHANGEABLE': True, + } + + def test_change_password(self): + self.authenticate('joe@lp.com') + with capture_signals() as mocks: + with self.client as client: + client.post('/change', + data=dict(password='password', + new_password='newpassword', + new_password_confirm='newpassword'), + follow_redirects=True) + self.assertEqual(mocks.signals_sent(), set([password_changed])) + user = self.app.security.datastore.find_user(email='joe@lp.com') + calls = mocks[password_changed] + self.assertEqual(len(calls), 1) + args, kwargs = calls[0] + self.assertTrue(compare_user(args[0], user)) + self.assertEqual(kwargs['app'], self.app) + + def test_change_password_invalid_password(self): + with capture_signals() as mocks: + self.client.post('/change', + data=dict(password='notpassword', + new_password='newpassword', + new_password_confirm='newpassword'), + follow_redirects=True) + self.assertEqual(mocks.signals_sent(), set()) + + def test_change_password_bad_password(self): + with capture_signals() as mocks: + self.client.post('/change', + data=dict(password='notpassword', + new_password='a', + new_password_confirm='a'), + follow_redirects=True) + self.assertEqual(mocks.signals_sent(), set()) + + def test_change_password_mismatch_password(self): + with capture_signals() as mocks: + self.client.post('/change', + data=dict(password='password', + new_password='newpassword', + new_password_confirm='notnewpassword'), + follow_redirects=True) + self.assertEqual(mocks.signals_sent(), set()) + + class PasswordlessTests(SecurityTest): AUTH_CONFIG = { From 4f9e23e0bc8a110c8a56b097c6de119f2005768f Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Sat, 12 Jan 2013 19:34:53 -0800 Subject: [PATCH 09/33] Fix email forms to have externally available links --- flask_security/templates/security/email/change_notice.html | 2 +- flask_security/templates/security/email/change_notice.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flask_security/templates/security/email/change_notice.html b/flask_security/templates/security/email/change_notice.html index 8280683..fdca72a 100644 --- a/flask_security/templates/security/email/change_notice.html +++ b/flask_security/templates/security/email/change_notice.html @@ -1,4 +1,4 @@

    Your password has been changed.

    {% if security.recoverable %} -

    If you did not change your password, click here to reset your password.

    +

    If you did not change your password, click here to reset your password.

    {% endif %} diff --git a/flask_security/templates/security/email/change_notice.txt b/flask_security/templates/security/email/change_notice.txt index df2d2d4..23b1084 100644 --- a/flask_security/templates/security/email/change_notice.txt +++ b/flask_security/templates/security/email/change_notice.txt @@ -1,5 +1,5 @@ Your password has been changed {% if security.recoverable %} If you did not change your password, click the link below to reset your password: -{{ url_for_security('forgot_password') }} +{{ url_for_security('forgot_password', _external=True) }} {% endif %} From cca9298e74a1985e81c5c9722f7bb7d123a3edd4 Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Sat, 12 Jan 2013 19:56:50 -0800 Subject: [PATCH 10/33] Fix and test redir to configurable view post change --- flask_security/forms.py | 2 +- .../templates/security/change_password.html | 1 - flask_security/views.py | 2 + tests/configured_tests.py | 37 ++++++++++++++++--- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/flask_security/forms.py b/flask_security/forms.py index 48f7b82..3d9c7e6 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -200,7 +200,7 @@ class ResetPasswordForm(Form, NewPasswordFormMixin, PasswordConfirmFormMixin): submit = SubmitField("Reset Password") -class ChangePasswordForm(Form, NextFormMixin, PasswordFormMixin): +class ChangePasswordForm(Form, PasswordFormMixin): """The default change password form""" new_password = PasswordField("New Password", diff --git a/flask_security/templates/security/change_password.html b/flask_security/templates/security/change_password.html index 02701c4..8ee3eb7 100644 --- a/flask_security/templates/security/change_password.html +++ b/flask_security/templates/security/change_password.html @@ -6,7 +6,6 @@ {{ render_field_with_errors(change_password_form.password) }} {{ render_field_with_errors(change_password_form.new_password) }} {{ render_field_with_errors(change_password_form.new_password_confirm) }} - {{ render_field(change_password_form.next) }} {{ render_field(change_password_form.submit) }} diff --git a/flask_security/views.py b/flask_security/views.py index 587a686..dfc87fd 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -296,6 +296,8 @@ def change_password(): change_user_password(current_user, form.new_password.data) if request.json is None: do_flash(*get_message('PASSWORD_CHANGE')) + return redirect(get_url(_security.post_change_view) or + get_url(_security.post_login_view)) if request.json: return _render_json(form) diff --git a/tests/configured_tests.py b/tests/configured_tests.py index af248d7..67d88c4 100644 --- a/tests/configured_tests.py +++ b/tests/configured_tests.py @@ -321,8 +321,8 @@ class ExpiredResetPasswordTest(SecurityTest): class ChangePasswordTest(SecurityTest): AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True, 'SECURITY_CHANGEABLE': True, - 'SECURITY_POST_CHANGE_VIEW': '/', } def test_change_password(self): @@ -362,12 +362,37 @@ class ChangePasswordTest(SecurityTest): def test_change_password_success(self): self.authenticate() - r = self.client.post('/change', data={ - 'password': 'password', - 'new_password': 'newpassword', - 'new_password_confirm': 'newpassword' - }, follow_redirects=True) + with self.app.extensions['mail'].record_messages() as outbox: + r = self.client.post('/change', data={ + 'password': 'password', + 'new_password': 'newpassword', + 'new_password_confirm': 'newpassword' + }, follow_redirects=True) + self.assertIn('You successfully changed your password', r.data) + self.assertIn('Home Page', r.data) + + self.assertEqual(len(outbox), 1) + self.assertIn("Your password has been changed", outbox[0].html) + self.assertIn("/reset", outbox[0].html) + + +class ChangePasswordPostViewTest(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_CHANGEABLE': True, + 'SECURITY_POST_CHANGE_VIEW': '/profile', + } + + def test_change_password_success(self): + self.authenticate() + r = self.client.post('/change', data={ + 'password': 'password', + 'new_password': 'newpassword', + 'new_password_confirm': 'newpassword' + }, follow_redirects=True) + + self.assertIn('Profile Page', r.data) class TrackableTests(SecurityTest): From c7af67f3a3cc40add4f4fdb43d5218cecfbbd6d1 Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Sat, 12 Jan 2013 23:53:31 -0800 Subject: [PATCH 11/33] Fix signals test, following redirect is a trap --- tests/signals_tests.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/signals_tests.py b/tests/signals_tests.py index 8ca2a56..164e6ec 100644 --- a/tests/signals_tests.py +++ b/tests/signals_tests.py @@ -172,8 +172,7 @@ class ChangeableSignalsTests(SecurityTest): client.post('/change', data=dict(password='password', new_password='newpassword', - new_password_confirm='newpassword'), - follow_redirects=True) + new_password_confirm='newpassword')) self.assertEqual(mocks.signals_sent(), set([password_changed])) user = self.app.security.datastore.find_user(email='joe@lp.com') calls = mocks[password_changed] From 3adb4afd6017661c7e29f63504aa8a77085cbd4a Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Sat, 12 Jan 2013 23:58:47 -0800 Subject: [PATCH 12/33] Minor wording fix --- flask_security/templates/security/email/change_notice.html | 2 +- flask_security/templates/security/email/change_notice.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flask_security/templates/security/email/change_notice.html b/flask_security/templates/security/email/change_notice.html index fdca72a..d1224cf 100644 --- a/flask_security/templates/security/email/change_notice.html +++ b/flask_security/templates/security/email/change_notice.html @@ -1,4 +1,4 @@

    Your password has been changed.

    {% if security.recoverable %} -

    If you did not change your password, click here to reset your password.

    +

    If you did not change your password, click here to reset it.

    {% endif %} diff --git a/flask_security/templates/security/email/change_notice.txt b/flask_security/templates/security/email/change_notice.txt index 23b1084..e74bd80 100644 --- a/flask_security/templates/security/email/change_notice.txt +++ b/flask_security/templates/security/email/change_notice.txt @@ -1,5 +1,5 @@ Your password has been changed {% if security.recoverable %} -If you did not change your password, click the link below to reset your password: +If you did not change your password, click the link below to reset it. {{ url_for_security('forgot_password', _external=True) }} {% endif %} From 1a0ddff82b2d1ffb1a9f0406f80b49431e46dd16 Mon Sep 17 00:00:00 2001 From: apahomov Date: Mon, 14 Jan 2013 10:54:48 +0400 Subject: [PATCH 13/33] Get auth token from JSON request. --- flask_security/decorators.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 5787679..930df56 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -48,6 +48,8 @@ def _check_token(): args_key = _security.token_authentication_key header_token = request.headers.get(header_key, None) token = request.args.get(args_key, header_token) + if request.json: + token = request.json.get(args_key, token) serializer = _security.remember_token_serializer try: From bbed019ca560f3d31a88c682adcb9a5526b639ed Mon Sep 17 00:00:00 2001 From: apahomov Date: Mon, 14 Jan 2013 15:45:18 +0400 Subject: [PATCH 14/33] Add auth_required decorator that allows multiple auth mechanisms --- flask_security/decorators.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 930df56..5e6ecd9 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -115,6 +115,35 @@ def auth_token_required(fn): return decorated +def auth_required(*auth_methods): + """ + Decorator that protects enpoints through multiple mechanisms + Example:: + + @app.route('/dashboard') + @auth_required('token', 'session') + def dashboard(): + return 'Dashboard' + + :param auth_methods: Specified mechanisms. + """ + login_mechanisms = { + 'token': lambda: _check_token(), + 'basic': lambda: _check_http_auth(), + 'session': lambda: current_user.is_authenticated() + } + + def wrapper(fn): + @wraps(fn) + def decorated_view(*args, **kwargs): + mechanisms = [login_mechanisms.get(method) for method in auth_methods] + if any(mechanisms): + return fn(*args, **kwargs) + return _get_unauthorized_response() + return decorated_view + return wrapper + + def roles_required(*roles): """Decorator which specifies that a user must have all the specified roles. Example:: From 3f9ca423bd1b3e59bcfa13249266d1c496b66c06 Mon Sep 17 00:00:00 2001 From: apahomov Date: Mon, 14 Jan 2013 16:11:09 +0400 Subject: [PATCH 15/33] Calling auth methods --- flask_security/decorators.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 5e6ecd9..229e2c6 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -137,8 +137,9 @@ def auth_required(*auth_methods): @wraps(fn) def decorated_view(*args, **kwargs): mechanisms = [login_mechanisms.get(method) for method in auth_methods] - if any(mechanisms): - return fn(*args, **kwargs) + for mechanism in mechanisms: + if mechanism and mechanism(): + return fn(*args, **kwargs) return _get_unauthorized_response() return decorated_view return wrapper From 39f62374aaccf70ee3770ab2a8a514bfd3f40b0e Mon Sep 17 00:00:00 2001 From: apahomov Date: Tue, 15 Jan 2013 10:30:48 +0400 Subject: [PATCH 16/33] Added tests --- tests/functional_tests.py | 18 ++++++++++++++++++ tests/test_app/__init__.py | 7 ++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 49e9be6..61fc92d 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -169,6 +169,24 @@ class DefaultSecurityTests(SecurityTest): self.assertEquals('Basic realm="My Realm"', r.headers['WWW-Authenticate']) + def test_multi_auth_basic(self): + r = self._get('/multi_auth', headers={ + 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:password") + }) + self.assertIn('Basic', r.data) + + def test_multi_auth_token(self): + r = self.json_authenticate() + data = json.loads(r.data) + token = data['response']['user']['authentication_token'] + r = self._get('/multi_auth?auth_token=' + token) + self.assertIn('Token', r.data) + + def test_multi_auth_session(self): + self.authenticate() + r = self._get('/multi_auth') + self.assertIn('Session', r.data) + def test_user_deleted_during_session_reverts_to_anonymous_user(self): self.authenticate() diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index 5cad502..863db79 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -4,7 +4,7 @@ from flask import Flask, render_template, current_app from flask.ext.mail import Mail from flask.ext.security import login_required, roles_required, roles_accepted from flask.ext.security.decorators import http_auth_required, \ - auth_token_required + auth_token_required, auth_required from flask.ext.security.utils import encrypt_password from werkzeug.local import LocalProxy @@ -50,6 +50,11 @@ def create_app(config): def token(): return render_template('index.html', content='Token Authentication') + @app.route('/multi_auth') + @auth_required('session', 'token', 'basic') + def multi_auth(): + return render_template('index.html', content='Session, Token, Basic auth') + @app.route('/post_logout') def post_logout(): return render_template('index.html', content='Post Logout') From 647e1a06d513f6fc84bc4b8a1fb7e2dc3ae41ce9 Mon Sep 17 00:00:00 2001 From: Eskil Heyn Olsen Date: Thu, 17 Jan 2013 20:35:23 -0800 Subject: [PATCH 17/33] Add test to ensure it is disabled --- tests/configured_tests.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/configured_tests.py b/tests/configured_tests.py index 67d88c4..0c59ddf 100644 --- a/tests/configured_tests.py +++ b/tests/configured_tests.py @@ -395,6 +395,18 @@ class ChangePasswordPostViewTest(SecurityTest): self.assertIn('Profile Page', r.data) +class ChangePasswordDisabledTest(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_CHANGEABLE': False, + } + + def test_change_password_endpoint_is_404(self): + self.authenticate() + r = self.client.get('/change', follow_redirects=True) + self.assertEqual(404, r.status_code) + + class TrackableTests(SecurityTest): AUTH_CONFIG = { From 68f81272869bddc5bedbf395fee6206205fc401a Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Thu, 24 Jan 2013 16:23:48 -0800 Subject: [PATCH 18/33] Updates dependencies to Flask>=0.9 after_this_request was introduced in 0.9-dev --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index e1a4faf..36a5f6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -Flask==0.8 Flask-Login==0.1 Flask-Principal==0.2 Flask-Script==0.3.2 Flask-WTF==0.5.4 passlib=1.5.3 +Flask>=0.9 diff --git a/setup.py b/setup.py index cfead93..4025e75 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ setup( include_package_data=True, platforms='any', install_requires=[ - 'Flask>=0.8', + 'Flask>=0.9', 'Flask-Login>=0.1.3', 'Flask-Mail>=0.7.3', 'Flask-Principal>=0.3.3', From 29af22bd6e52beb897a214de71c4543a1d8c5e07 Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Thu, 24 Jan 2013 16:24:50 -0800 Subject: [PATCH 19/33] Updated requirements.txt to reflect setup.py Also fixes a typo (`passlib=1.5.3` is not a valid line) --- requirements.txt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index 36a5f6c..e17f9ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ -Flask-Login==0.1 -Flask-Principal==0.2 -Flask-Script==0.3.2 -Flask-WTF==0.5.4 -passlib=1.5.3 Flask>=0.9 +Flask-Login>=0.1.3 +Flask-Mail>=0.7.3 +Flask-Principal>=0.3.3 +Flask-WTF>=0.8 +itsdangerous>=0.17 +passlib>=1.6.1 From 5687f2f5a9c8585842a3e405054183e541525840 Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Fri, 25 Jan 2013 16:52:50 -0800 Subject: [PATCH 20/33] Adds support for flask-peewee --- flask_security/__init__.py | 2 +- flask_security/datastore.py | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/flask_security/__init__.py b/flask_security/__init__.py index f665380..b1176cb 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -13,7 +13,7 @@ __version__ = '1.5.4' from .core import Security, RoleMixin, UserMixin, AnonymousUser, current_user -from .datastore import SQLAlchemyUserDatastore, MongoEngineUserDatastore +from .datastore import SQLAlchemyUserDatastore, MongoEngineUserDatastore, PeeweeUserDatastore from .decorators import auth_token_required, http_auth_required, \ login_required, roles_accepted, roles_required from .forms import ForgotPasswordForm, LoginForm, RegisterForm, \ diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 7a958be..747740a 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -44,6 +44,15 @@ class MongoEngineDatastore(Datastore): model.delete() +class PeeweeDatastore(Datastore): + def put(self, model): + model.save() + return model + + def delete(self, model): + model.delete_instance() + + class UserDatastore(object): """Abstracted user datastore. @@ -179,3 +188,66 @@ class MongoEngineUserDatastore(MongoEngineDatastore, UserDatastore): def find_role(self, role): return self.role_model.objects(name=role).first() + + +class PeeweeUserDatastore(PeeweeDatastore, UserDatastore): + """A PeeweeD datastore implementation for Flask-Security that assumes + the use of the Flask-Peewee extension. + + :param user_model: A user model class definition + :param role_model: A role model class definition + :param role_link: A model implementing the many-to-many user-role relation + """ + def __init__(self, db, user_model, role_model, role_link): + PeeweeDatastore.__init__(self, db) + UserDatastore.__init__(self, user_model, role_model) + self.UserRole = role_link + + def find_user(self, **kwargs): + try: + return self.user_model.filter(**kwargs).get() + except self.user_model.DoesNotExist: + return None + + def find_role(self, role): + try: + return self.role_model.filter(name=role).get() + except self.role_model.DoesNotExist: + return None + + def create_user(self, **kwargs): + """Creates and returns a new user from the given parameters.""" + roles = kwargs.pop('roles', []) + user = self.user_model(**self._prepare_create_user_args(**kwargs)) + user = self.put(user) + for role in roles: + self.add_role_to_user(user, role) + return user + + + def add_role_to_user(self, user, role): + """Adds a role tp a user + + :param user: The user to manipulate + :param role: The role to add to the user + """ + user, role = self._prepare_role_modify_args(user, role) + if self.UserRole.select().where(self.UserRole.user==user, self.UserRole.role==role).count(): + return False + else: + self.UserRole.create(user=user, role=role) + return True + + def remove_role_from_user(self, user, role): + """Removes a role from a user + + :param user: The user to manipulate + :param role: The role to remove from the user + """ + user, role = self._prepare_role_modify_args(user, role) + if self.UserRole.select().where(self.UserRole.user==user, self.UserRole.role==role).count(): + self.UserRole.delete().where(self.UserRole.user==user, self.UserRole.role==role) + return True + else: + return False + From 70b11d9015f7f3b5b8bb31fd4b86c536e5fddf2e Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Fri, 25 Jan 2013 16:53:01 -0800 Subject: [PATCH 21/33] Unit-tests for flask-peewee --- .travis.yml | 2 +- setup.py | 1 + tests/functional_tests.py | 7 ++++ tests/test_app/peewee_app.py | 62 ++++++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/test_app/peewee_app.py diff --git a/.travis.yml b/.travis.yml index 42c3b47..a182d2f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ python: install: - pip install . --quiet --use-mirrors - "if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install importlib --quiet --use-mirrors; fi" - - pip install nose simplejson Flask-SQLAlchemy Flask-MongoEngine Flask-Mail py-bcrypt MySQL-python --quiet --use-mirrors + - pip install nose simplejson Flask-SQLAlchemy Flask-MongoEngine Flask-Peewee Flask-Mail py-bcrypt MySQL-python --quiet --use-mirrors before_script: - mysql -e 'create database flask_security_test;' diff --git a/setup.py b/setup.py index 4025e75..05e8986 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ setup( 'nose', 'Flask-SQLAlchemy', 'Flask-MongoEngine', + 'Flask-Peewee', 'py-bcrypt', 'simplejson' ], diff --git a/tests/functional_tests.py b/tests/functional_tests.py index c68625a..fd8d60a 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -224,6 +224,13 @@ class MongoEngineSecurityTests(DefaultSecurityTests): return create_app(auth_config, **kwargs) +class PeeweeSecurityTests(DefaultSecurityTests): + + def _create_app(self, auth_config, **kwargs): + from tests.test_app.peewee_app import create_app + return create_app(auth_config, **kwargs) + + class DefaultDatastoreTests(SecurityTest): def test_add_role_to_user(self): diff --git a/tests/test_app/peewee_app.py b/tests/test_app/peewee_app.py new file mode 100644 index 0000000..ae404a2 --- /dev/null +++ b/tests/test_app/peewee_app.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- + +import sys +import os + +sys.path.pop(0) +sys.path.insert(0, os.getcwd()) + +from flask_peewee.db import Database +from peewee import * +from flask.ext.security import Security, UserMixin, RoleMixin, \ + PeeweeUserDatastore + +from tests.test_app import create_app as create_base_app, populate_data, \ + add_context_processors + +def create_app(config, **kwargs): + app = create_base_app(config) + app.config['DATABASE'] = { + 'name': 'example2.db', + 'engine': 'peewee.SqliteDatabase', + } + db = Database(app) + + class Role(db.Model, RoleMixin): + name = TextField(unique=True) + description = TextField(null=True) + + class User(db.Model, UserMixin): + email = TextField() + password = TextField() + last_login_at = DateTimeField(null=True) + current_login_at = DateTimeField(null=True) + last_login_ip = TextField(null=True) + current_login_ip = TextField(null=True) + login_count = IntegerField(null=True) + active = BooleanField(default=True) + confirmed_at = DateTimeField(null=True) + + class UserRoles(db.Model): + """ Peewee does not have built-in many-to-many support, so we have to + create this mapping class to link users to roles.""" + user = ForeignKeyField(User, related_name='roles') + role = ForeignKeyField(Role, related_name='users') + name = property(lambda self: self.role.name) + description = property(lambda self: self.role.description) + + @app.before_first_request + def before_first_request(): + for Model in (Role, User, UserRoles): + Model.drop_table(fail_silently=True) + Model.create_table(fail_silently=True) + populate_data(app.config.get('USER_COUNT', None)) + + app.security = Security(app, datastore=PeeweeUserDatastore(db, User, Role, UserRoles), **kwargs) + + add_context_processors(app.security) + + return app + +if __name__ == '__main__': + create_app({}).run() From 423e430e04d234be006dcb405c205a821f6b7e84 Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Fri, 25 Jan 2013 16:54:18 -0800 Subject: [PATCH 22/33] Docs for flask-peewee --- docs/api.rst | 4 +++ docs/index.rst | 11 +++--- docs/models.rst | 14 ++++---- docs/quickstart.rst | 82 +++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 96 insertions(+), 15 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 4b86c60..c4ff952 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -49,6 +49,10 @@ Datastores :members: :inherited-members: +.. autoclass:: flask_security.datastore.PeeweeUserDatastore + :members: + :inherited-members: + Signals ------- diff --git a/docs/index.rst b/docs/index.rst index 97713c1..083de61 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,7 +10,7 @@ Flask application. They include: 4. Basic HTTP authentication 5. Token based authentication 6. Token based account activation (optional) -7. Token based password recovery/resetting (optional) +7. Token based password recovery / resetting (optional) 8. User registration (optional) 9. Login tracking (optional) @@ -27,10 +27,11 @@ and libraries. They include: Additionally, it assumes you'll be using a common library for your database connections and model definitions. Flask-Security supports the following Flask -extensions out of the box for data persistance: +extensions out of the box for data persistence: -1. `Flask-SQLAlchemy `_ -2. `Flask-MongoEngine `_ +1. `Flask-SQLAlchemy `_ +2. `Flask-MongoEngine `_ +3. `Flask-Peewee `_ -.. include:: contents.rst.inc \ No newline at end of file +.. include:: contents.rst.inc diff --git a/docs/models.rst b/docs/models.rst index 6ea6b15..6fea2e4 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -1,12 +1,12 @@ Models ====== -Flask-Security assumes you'll be using libraries such as SQLAlchemy or -MongoEngine to define a data model that includes a `User` and `Role` model. The -fields on your models must follow a particular convention depending on the -functionality your app requires. Aside from this, you're free to add any -additional fields to your model(s) if you want. At the bear minimum your `User` -and `Role` model should include the following fields: +Flask-Security assumes you'll be using libraries such as SQLAlchemy, +MongoEngine or Peewee to define a data model that includes a `User` and +`Role` model. The fields on your models must follow a particular convention +depending on the functionality your app requires. Aside from this, you're +free to add any additional fields to your model(s) if you want. At the bear +minimum your `User` and `Role` model should include the following fields: **User** @@ -48,4 +48,4 @@ additional fields: * ``current_login_at`` * ``last_login_ip`` * ``current_login_ip`` -* ``login_count`` \ No newline at end of file +* ``login_count`` diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 69aff7c..0443a0e 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -3,6 +3,7 @@ Quick Start - `Basic SQLAlchemy Application <#basic-sqlalchemy-application>`_ - `Basic MongoEngine Application <#basic-mongoengine-application>`_ +- `Basic Peewee Application <#basic-peewee-application>`_ - `Mail Configuration <#mail-configuration>`_ Basic SQLAlchemy Application @@ -21,7 +22,7 @@ SQLAlchemy Application ~~~~~~~~~~~~~~~~~~~~~~ The following code sample illustrates how to get started as quickly as -possible using SQLAlchemy. +possible using SQLAlchemy: :: @@ -94,7 +95,9 @@ MongoEngine Application ~~~~~~~~~~~~~~~~~~~~~~~ The following code sample illustrates how to get started as quickly as -possible using MongoEngine:: +possible using MongoEngine: + +:: from flask import Flask, render_template from flask.ext.mongoengine import MongoEngine @@ -144,6 +147,79 @@ possible using MongoEngine:: app.run() +Basic Peewee Application +======================== + +Peewee Install requirements +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + $ mkvirtualenv + $ pip install flask-security flask-peewee + +Peewee Application +~~~~~~~~~~~~~~~~~~ + +The following code sample illustrates how to get started as quickly as +possible using Peewee: + +:: + + from flask import Flask, render_template + from flask_peewee.db import Database + from peewee import * + from flask.ext.security import Security, PeeweeUserDatastore, \ + UserMixin, RoleMixin, login_required + + # Create app + app = Flask(__name__) + app.config['DEBUG'] = True + app.config['SECRET_KEY'] = 'super-secret' + app.config['DATABASE'] = { + 'name': 'example.db', + 'engine': 'peewee.SqliteDatabase', + } + + # Create database connection object + db = Database(app) + + class Role(Model, RoleMixin): + name = TextField(unique=True) + description = TextField(null=True) + + class User(Model, UserMixin): + email = TextField() + password = TextField() + active = BooleanField(default=True) + confirmed_at = DateTimeField(null=True) + + class UserRoles(Model): + user = ForeignKeyField(User, related_name='roles') + role = ForeignKeyField(Role, related_name='users') + + # Setup Flask-Security + user_datastore = PeeweeUserDatastore(db, User, Role) + security = Security(app, user_datastore) + + # Create a user to test with + @app.before_first_request + def create_user(): + for Model in (Role, User, UserRoles): + Model.drop_table(fail_silently=True) + Model.create_table(fail_silently=True) + user_datastore.create_user(email='matt@nobien.net', password='password') + + # Views + @app.route('/') + @login_required + def home(): + return render_template('index.html') + + if __name__ == '__main__': + app.run() + + Mail Configuration =================== @@ -168,4 +244,4 @@ the basic application code in the previous section:: To learn more about the various Flask-Mail settings to configure it to work with your particular email server configuration, please see the -`Flask-Mail documentation `_. \ No newline at end of file +`Flask-Mail documentation `_. From 46c2355a7e3acfb4318b872349bc33a38bb01a28 Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Fri, 25 Jan 2013 16:58:21 -0800 Subject: [PATCH 23/33] FIxes peewee description on quickstart --- docs/quickstart.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 0443a0e..8d288e8 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -195,11 +195,16 @@ possible using Peewee: confirmed_at = DateTimeField(null=True) class UserRoles(Model): + # Because peewee does not come with built-in many-to-many + # relationships, we need this intermediary class to link + # user to roles. user = ForeignKeyField(User, related_name='roles') role = ForeignKeyField(Role, related_name='users') + name = property(lambda self: self.role.name) + description = property(lambda self: self.role.description) # Setup Flask-Security - user_datastore = PeeweeUserDatastore(db, User, Role) + user_datastore = PeeweeUserDatastore(db, User, Role, UserRoles) security = Security(app, user_datastore) # Create a user to test with From e3e96d546a223b3e919b9cf1d1879968412dd6b6 Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Fri, 25 Jan 2013 16:59:48 -0800 Subject: [PATCH 24/33] Another small fix for the peewee docs --- docs/quickstart.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 8d288e8..2e21d37 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -184,17 +184,17 @@ possible using Peewee: # Create database connection object db = Database(app) - class Role(Model, RoleMixin): + class Role(db.Model, RoleMixin): name = TextField(unique=True) description = TextField(null=True) - class User(Model, UserMixin): + class User(db.Model, UserMixin): email = TextField() password = TextField() active = BooleanField(default=True) confirmed_at = DateTimeField(null=True) - class UserRoles(Model): + class UserRoles(db.Model): # Because peewee does not come with built-in many-to-many # relationships, we need this intermediary class to link # user to roles. From aea5b916492826dd82e353f9436c182385c7f9ef Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Mon, 28 Jan 2013 18:57:19 -0800 Subject: [PATCH 25/33] Method stub parameters and docs for find_role didn't match implementations. --- flask_security/datastore.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 747740a..f5ccac4 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -82,11 +82,11 @@ class UserDatastore(object): return kwargs def find_user(self, **kwargs): - """Returns a user matching the provided paramters.""" + """Returns a user matching the provided parameters.""" raise NotImplementedError - def find_role(self, **kwargs): - """Returns a role matching the provided paramters.""" + def find_role(self, name): + """Returns a role matching the provided name.""" raise NotImplementedError def add_role_to_user(self, user, role): From 462fb1ae7ed71d76f5a02ee3ce9e6f615ba0bf21 Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Mon, 28 Jan 2013 18:58:11 -0800 Subject: [PATCH 26/33] Convenience method for finding or creating a role `datastore. find_or_create_role("admin")` will now always return a role with the name admin; useful for initialisation, --- flask_security/datastore.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/flask_security/datastore.py b/flask_security/datastore.py index f5ccac4..293a9ac 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -146,6 +146,13 @@ class UserDatastore(object): role = self.role_model(**kwargs) return self.put(role) + def find_or_create_role(self, name, **kwargs): + """Returns a role matching the given name or creates it with any + additionally provided parameters + """ + kwrags["name"] = name + return self.find_role(name) or self.create_role(**kwargs) + def create_user(self, **kwargs): """Creates and returns a new user from the given parameters.""" From 51e06bdbb07c7a0182fded7d351fa5ac02166c5e Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Tue, 29 Jan 2013 15:46:59 -0800 Subject: [PATCH 27/33] Fixes typo in find_or_create_role --- flask_security/datastore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 293a9ac..d0b3bba 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -150,7 +150,7 @@ class UserDatastore(object): """Returns a role matching the given name or creates it with any additionally provided parameters """ - kwrags["name"] = name + kwargs["name"] = name return self.find_role(name) or self.create_role(**kwargs) def create_user(self, **kwargs): From 34b3bf9e8060c73b78fdfeef273e00efa3feb207 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 1 Feb 2013 17:18:31 -0500 Subject: [PATCH 28/33] Fix CSRF functionality for LoginForm The login form was not respecting csrf validation. I've adjusted the tests as well to always send a CSRF token along. This now requires all requests to pass a csrf token. If performing plain AJAX requests the token will have to be extracted from the form in some way. Fixes #86 --- flask_security/forms.py | 11 ++++---- tests/__init__.py | 36 ++++++++++++++++--------- tests/configured_tests.py | 57 +++++++++++++++++---------------------- tests/signals_tests.py | 45 ++++++++++++------------------- 4 files changed, 72 insertions(+), 77 deletions(-) diff --git a/flask_security/forms.py b/flask_security/forms.py index 64ae022..27dd675 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -45,10 +45,7 @@ def valid_user_email(form, field): class Form(BaseForm): def __init__(self, *args, **kwargs): if current_app.testing: - csrf_enabled = False - else: - csrf_enabled = request.json is None - kwargs.setdefault('csrf_enabled', csrf_enabled) + self.TIME_LIMIT = None super(Form, self).__init__(*args, **kwargs) @@ -83,6 +80,7 @@ class NewPasswordFormMixin(): validators=[password_required, Length(min=6, max=128)]) + class PasswordConfirmFormMixin(): password_confirm = PasswordField("Retype Password", validators=[EqualTo('password', message="Passwords do not match")]) @@ -147,6 +145,7 @@ class PasswordlessLoginForm(Form, UserEmailFormMixin): class LoginForm(Form, NextFormMixin): """The default login form""" + email = TextField('Email Address') password = PasswordField('Password') remember = BooleanField("Remember Me") @@ -156,7 +155,8 @@ class LoginForm(Form, NextFormMixin): super(LoginForm, self).__init__(*args, **kwargs) def validate(self): - super(LoginForm, self).validate() + if not super(LoginForm, self).validate(): + return False if self.email.data.strip() == '': self.email.errors.append('Email not provided') @@ -187,6 +187,7 @@ class ConfirmRegisterForm(Form, RegisterFormMixin, UniqueEmailFormMixin, NewPasswordFormMixin): pass + class RegisterForm(ConfirmRegisterForm, PasswordConfirmFormMixin): pass diff --git a/tests/__init__.py b/tests/__init__.py index 53e0d9b..35ceb79 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,8 +1,13 @@ # -*- coding: utf-8 -*- +import hmac + +from hashlib import sha1 from unittest import TestCase + from tests.test_app.sqlalchemy import create_app + class SecurityTest(TestCase): APP_KWARGS = { @@ -21,6 +26,13 @@ class SecurityTest(TestCase): self.app = app self.client = app.test_client() + with self.client.session_transaction() as session: + session['csrf'] = 'csrf_token' + + csrf_hmac = hmac.new(self.app.config['SECRET_KEY'], + 'csrf_token'.encode('utf8'), digestmod=sha1) + self.csrf_token = '##' + csrf_hmac.hexdigest() + def _create_app(self, auth_config, **kwargs): return create_app(auth_config, **kwargs) @@ -30,30 +42,30 @@ class SecurityTest(TestCase): headers=headers) def _post(self, route, data=None, content_type=None, follow_redirects=True, headers=None): + if isinstance(data, dict): + data['csrf_token'] = self.csrf_token + return self.client.post(route, data=data, follow_redirects=follow_redirects, content_type=content_type or 'application/x-www-form-urlencoded', headers=headers) def register(self, email, password='password'): - data = dict(email=email, password=password) + data = dict(email=email, password=password, csrf_token=self.csrf_token) return self.client.post('/register', data=data, follow_redirects=True) def authenticate(self, email="matt@lp.com", password="password", endpoint=None, **kwargs): data = dict(email=email, password=password, remember='y') - r = self._post(endpoint or '/login', data=data, **kwargs) - return r + return self._post(endpoint or '/login', data=data, **kwargs) def json_authenticate(self, email="matt@lp.com", password="password", endpoint=None): - data = """ -{ - "email": "%s", - "password": "%s" -} -""" - return self._post(endpoint or '/login', - content_type="application/json", - data=data % (email, password)) + data = """{ + "email": "%s", + "password": "%s", + "csrf_token": "%s" + }""" + return self._post(endpoint or '/login', content_type="application/json", + data=data % (email, password, self.csrf_token)) def logout(self, endpoint=None): return self._get(endpoint or '/logout', follow_redirects=True) diff --git a/tests/configured_tests.py b/tests/configured_tests.py index f1e0d7d..5e9517a 100644 --- a/tests/configured_tests.py +++ b/tests/configured_tests.py @@ -67,7 +67,7 @@ class ConfiguredSecurityTests(SecurityTest): self.assertIn('Post Register', r.data) def test_register_json(self): - data = '{ "email": "dude@lp.com", "password": "password" }' + data = '{ "email": "dude@lp.com", "password": "password", "csrf_token":"%s" }' % self.csrf_token r = self._post('/register', data=data, content_type='application/json') data = json.loads(r.data) self.assertEquals(data['meta']['code'], 200) @@ -117,7 +117,7 @@ class RegisterableTests(SecurityTest): data = dict(email='dude@lp.com', password='password', password_confirm='password') - self.client.post('/register', data=data, follow_redirects=True) + self._post('/register', data=data, follow_redirects=True) r = self.authenticate('dude@lp.com') self.assertIn('Hello dude@lp.com', r.data) @@ -145,7 +145,7 @@ class ConfirmableTests(SecurityTest): self.client.get('/confirm/' + token, follow_redirects=True) self.logout() - r = self.client.post('/confirm', data=dict(email=e)) + r = self._post('/confirm', data=dict(email=e)) self.assertIn(self.get_message('ALREADY_CONFIRMED'), r.data) def test_register_sends_confirmation_email(self): @@ -231,7 +231,7 @@ class LoginWithoutImmediateConfirmTests(SecurityTest): e = 'dude@lp.com' p = 'password' data = dict(email=e, password=p, password_confirm=p) - r = self.client.post('/register', data=data, follow_redirects=True) + r = self._post('/register', data=data, follow_redirects=True) self.assertIn(e, r.data) @@ -245,7 +245,7 @@ class RecoverableTests(SecurityTest): def test_reset_view(self): with capture_reset_password_requests() as requests: - r = self.client.post('/reset', + r = self._post('/reset', data=dict(email='joe@lp.com'), follow_redirects=True) t = requests[0]['token'] @@ -255,23 +255,23 @@ class RecoverableTests(SecurityTest): def test_forgot_post_sends_email(self): with capture_reset_password_requests(): with self.app.extensions['mail'].record_messages() as outbox: - self.client.post('/reset', data=dict(email='joe@lp.com')) + self._post('/reset', data=dict(email='joe@lp.com')) self.assertEqual(len(outbox), 1) def test_forgot_password_json(self): - r = self.client.post('/reset', data='{"email": "matt@lp.com"}', + r = self._post('/reset', data='{"email": "matt@lp.com"}', content_type="application/json") self.assertEquals(r.status_code, 200) def test_forgot_password_invalid_email(self): - r = self.client.post('/reset', + r = self._post('/reset', data=dict(email='larry@lp.com'), follow_redirects=True) self.assertIn("Specified user does not exist", r.data) def test_reset_password_with_valid_token(self): with capture_reset_password_requests() as requests: - r = self.client.post('/reset', + r = self._post('/reset', data=dict(email='joe@lp.com'), follow_redirects=True) t = requests[0]['token'] @@ -303,14 +303,13 @@ class ExpiredResetPasswordTest(SecurityTest): def test_reset_password_with_expired_token(self): with capture_reset_password_requests() as requests: - r = self.client.post('/reset', - data=dict(email='joe@lp.com'), - follow_redirects=True) + r = self._post('/reset', data=dict(email='joe@lp.com'), + follow_redirects=True) t = requests[0]['token'] time.sleep(1) - r = self.client.post('/reset/' + t, data={ + r = self._post('/reset/' + t, data={ 'password': 'newpassword', 'password_confirm': 'newpassword' }, follow_redirects=True) @@ -348,20 +347,19 @@ class PasswordlessTests(SecurityTest): def test_login_request_for_inactive_user(self): msg = self.app.config['SECURITY_MSG_DISABLED_ACCOUNT'][0] - r = self.client.post('/login', - data=dict(email='tiya@lp.com'), - follow_redirects=True) + r = self._post('/login', data=dict(email='tiya@lp.com'), + follow_redirects=True) self.assertIn(msg, r.data) def test_request_login_token_with_json_and_valid_email(self): - data = '{"email": "matt@lp.com", "password": "password"}' - r = self.client.post('/login', data=data, content_type='application/json') + data = '{"email": "matt@lp.com", "password": "password", "csrf_token":"%s"}' % self.csrf_token + r = self._post('/login', data=data, content_type='application/json') self.assertEquals(r.status_code, 200) self.assertNotIn('error', r.data) def test_request_login_token_with_json_and_invalid_email(self): data = '{"email": "nobody@lp.com", "password": "password"}' - r = self.client.post('/login', data=data, content_type='application/json') + r = self._post('/login', data=data, content_type='application/json') self.assertIn('errors', r.data) def test_request_login_token_sends_email_and_can_login(self): @@ -370,9 +368,8 @@ class PasswordlessTests(SecurityTest): with capture_passwordless_login_requests() as requests: with self.app.extensions['mail'].record_messages() as outbox: - r = self.client.post('/login', - data=dict(email=e), - follow_redirects=True) + r = self._post('/login', data=dict(email=e), + follow_redirects=True) self.assertEqual(len(outbox), 1) @@ -401,9 +398,8 @@ class PasswordlessTests(SecurityTest): def test_token_login_when_already_authenticated(self): with capture_passwordless_login_requests() as requests: - self.client.post('/login', - data=dict(email='matt@lp.com'), - follow_redirects=True) + self._post('/login', data=dict(email='matt@lp.com'), + follow_redirects=True) token = requests[0]['login_token'] r = self.client.get('/login/' + token, follow_redirects=True) @@ -431,9 +427,7 @@ class ExpiredLoginTokenTests(SecurityTest): e = 'matt@lp.com' with capture_passwordless_login_requests() as requests: - self.client.post('/login', - data=dict(email=e), - follow_redirects=True) + self._post('/login', data=dict(email=e), follow_redirects=True) token = requests[0]['login_token'] time.sleep(1.25) @@ -467,7 +461,7 @@ class AsyncMailTaskTests(SecurityTest): def send_email(msg): self.mail_sent = True - self.client.post('/reset', data=dict(email='matt@lp.com')) + self._post('/reset', data=dict(email='matt@lp.com')) self.assertTrue(self.mail_sent) @@ -542,9 +536,8 @@ class RecoverableExtendFormsTest(SecurityTest): def test_reset_password(self): with capture_reset_password_requests() as requests: - self.client.post('/reset', - data=dict(email='joe@lp.com'), - follow_redirects=True) + self._post('/reset', data=dict(email='joe@lp.com'), + follow_redirects=True) token = requests[0]['token'] r = self._get('/reset/' + token) self.assertIn("My Reset Password Submit Field", r.data) diff --git a/tests/signals_tests.py b/tests/signals_tests.py index 51fde0f..35f067d 100644 --- a/tests/signals_tests.py +++ b/tests/signals_tests.py @@ -32,7 +32,7 @@ class RegisterableSignalsTests(SecurityTest): self.assertIn('confirm_token', args[0]) self.assertEqual(kwargs['app'], self.app) - def test_register(self): + def test_register_without_password(self): e = 'dude@lp.com' with capture_signals() as mocks: self.register(e, password='') @@ -111,9 +111,8 @@ class RecoverableSignalsTests(SecurityTest): def test_reset_password_request(self): with capture_signals() as mocks: - self.client.post('/reset', - data=dict(email='joe@lp.com'), - follow_redirects=True) + self._post('/reset', data=dict(email='joe@lp.com'), + follow_redirects=True) self.assertEqual(mocks.signals_sent(), set([reset_password_instructions_sent])) user = self.app.security.datastore.find_user(email='joe@lp.com') calls = mocks[reset_password_instructions_sent] @@ -125,15 +124,12 @@ class RecoverableSignalsTests(SecurityTest): def test_reset_password(self): with capture_reset_password_requests() as requests: - self.client.post('/reset', - data=dict(email='joe@lp.com'), - follow_redirects=True) + self._post('/reset', data=dict(email='joe@lp.com'), + follow_redirects=True) token = requests[0]['token'] with capture_signals() as mocks: - self.client.post('/reset/' + token, - data=dict(password='newpassword', - password_confirm='newpassword'), - follow_redirects=True) + data = dict(password='newpassword', password_confirm='newpassword') + self._post('/reset/' + token, data, follow_redirects=True) self.assertEqual(mocks.signals_sent(), set([password_reset])) user = self.app.security.datastore.find_user(email='joe@lp.com') calls = mocks[password_reset] @@ -144,17 +140,14 @@ class RecoverableSignalsTests(SecurityTest): def test_reset_password_invalid_emails(self): with capture_signals() as mocks: - self.client.post('/reset', - data=dict(email='nobody@lp.com'), - follow_redirects=True) + self._post('/reset', data=dict(email='nobody@lp.com'), + follow_redirects=True) self.assertEqual(mocks.signals_sent(), set()) def test_reset_password_invalid_token(self): with capture_signals() as mocks: - self.client.post('/reset/bogus', - data=dict(password='newpassword', - password_confirm='newpassword'), - follow_redirects=True) + data = dict(password='newpassword', password_confirm='newpassword') + self._post('/reset/bogus', data, follow_redirects=True) self.assertEqual(mocks.signals_sent(), set()) @@ -166,25 +159,22 @@ class PasswordlessTests(SecurityTest): def test_login_request_for_inactive_user(self): with capture_signals() as mocks: - self.client.post('/login', - data=dict(email='tiya@lp.com'), - follow_redirects=True) + self._post('/login', data=dict(email='tiya@lp.com'), + follow_redirects=True) self.assertEqual(mocks.signals_sent(), set()) def test_login_request_for_invalid_email(self): with capture_signals() as mocks: - self.client.post('/login', - data=dict(email='nobody@lp.com'), - follow_redirects=True) + self._post('/login', data=dict(email='nobody@lp.com'), + follow_redirects=True) self.assertEqual(mocks.signals_sent(), set()) def test_request_login_token_sends_email_and_can_login(self): e = 'matt@lp.com' with capture_signals() as mocks: - self.client.post('/login', - data=dict(email=e), - follow_redirects=True) + self._post('/login', data=dict(email=e), follow_redirects=True) + self.assertEqual(mocks.signals_sent(), set([login_instructions_sent])) user = self.app.security.datastore.find_user(email='matt@lp.com') calls = mocks[login_instructions_sent] @@ -193,4 +183,3 @@ class PasswordlessTests(SecurityTest): self.assertTrue(compare_user(args[0]['user'], user)) self.assertIn('login_token', args[0]) self.assertEqual(kwargs['app'], self.app) - From 012781103a42b6a1bf533e532389efba0cbe0fba Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 1 Feb 2013 17:25:30 -0500 Subject: [PATCH 29/33] Update CHANGES --- CHANGES | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGES b/CHANGES index d4dba59..bb59cb3 100644 --- a/CHANGES +++ b/CHANGES @@ -4,6 +4,16 @@ Flask-Security Changelog Here you can see the full list of changes between each Flask-Security release. +Version 1.5.5 +------------- + +Not yet released + +- Password hashing is now more flexible and can be changed +- Flask-Login messages are configurable +- AJAX requests must now send a CSRF token for security reasons + + Version 1.5.4 ------------- From c49d9b57eddc83f1aab1b85bd3971b5f29d1dcf7 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 1 Feb 2013 17:32:54 -0500 Subject: [PATCH 30/33] Make login form messages configurable --- flask_security/core.py | 6 +++++- flask_security/forms.py | 8 ++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index b30af10..7a62699 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -91,10 +91,14 @@ _default_messages = { 'LOGIN_EMAIL_SENT': ('Instructions to login have been sent to %(email)s.', 'success'), 'INVALID_LOGIN_TOKEN': ('Invalid login token.', 'error'), 'DISABLED_ACCOUNT': ('Account is disabled.', 'error'), + 'EMAIL_NOT_PROVIDED': ('Email not provided', 'error'), + 'PASSWORD_NOT_PROVIDED': ('Password not provided', 'error'), + 'USER_DOES_NOT_EXIST': ('Specified user does not exist', 'error'), + 'INVALID_PASSWORD': ('Invalid password', 'error'), 'PASSWORDLESS_LOGIN_SUCCESSFUL': ('You have successfuly logged in.', 'success'), 'PASSWORD_RESET': ('You successfully reset your password and you have been logged in automatically.', 'success'), 'LOGIN': ('Please log in to access this page.', 'info'), - 'REFRESH': ('Please reauthenticate to access this page.', 'info') + 'REFRESH': ('Please reauthenticate to access this page.', 'info'), } _allowed_password_hash_schemes = [ diff --git a/flask_security/forms.py b/flask_security/forms.py index 27dd675..ccdfc08 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -159,20 +159,20 @@ class LoginForm(Form, NextFormMixin): return False if self.email.data.strip() == '': - self.email.errors.append('Email not provided') + self.email.errors.append(get_message('EMAIL_NOT_PROVIDED')[0]) return False if self.password.data.strip() == '': - self.password.errors.append('Password not provided') + self.password.errors.append(get_message('PASSWORD_NOT_PROVIDED')[0]) return False self.user = _datastore.find_user(email=self.email.data) if self.user is None: - self.email.errors.append('Specified user does not exist') + self.email.errors.append(get_message('USER_DOES_NOT_EXIST')[0]) return False if not verify_and_update_password(self.password.data, self.user): - self.password.errors.append('Invalid password') + self.password.errors.append(get_message('INVALID_PASSWORD')[0]) return False if requires_confirmation(self.user): self.email.errors.append(get_message('CONFIRMATION_REQUIRED')[0]) From 0ea1e0c03d5112f539c3df2a846d9be88aaf80c9 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 1 Feb 2013 17:33:15 -0500 Subject: [PATCH 31/33] Update CHANGES --- CHANGES | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES b/CHANGES index bb59cb3..0bb5452 100644 --- a/CHANGES +++ b/CHANGES @@ -12,6 +12,7 @@ Not yet released - Password hashing is now more flexible and can be changed - Flask-Login messages are configurable - AJAX requests must now send a CSRF token for security reasons +- Login form messages are now configurable Version 1.5.4 From eca83f83ac740f3126f06eaaf3e4cf7e7fafb75a Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 1 Feb 2013 17:37:03 -0500 Subject: [PATCH 32/33] Test configured login form messages better --- tests/functional_tests.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index c68625a..f1e070d 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -35,23 +35,23 @@ class DefaultSecurityTests(SecurityTest): def test_unprovided_username(self): r = self.authenticate("") - self.assertIn("Email not provided", r.data) + self.assertIn(self.get_message('EMAIL_NOT_PROVIDED'), r.data) def test_unprovided_password(self): r = self.authenticate(password="") - self.assertIn("Password not provided", r.data) + self.assertIn(self.get_message('PASSWORD_NOT_PROVIDED'), r.data) def test_invalid_user(self): r = self.authenticate(email="bogus@bogus.com") - self.assertIn("Specified user does not exist", r.data) + self.assertIn(self.get_message('USER_DOES_NOT_EXIST'), r.data) def test_bad_password(self): r = self.authenticate(password="bogus") - self.assertIn("Invalid password", r.data) + self.assertIn(self.get_message('INVALID_PASSWORD'), r.data) def test_inactive_user(self): r = self.authenticate("tiya@lp.com", "password") - self.assertIn("Account is disabled", r.data) + self.assertIn(self.get_message('DISABLED_ACCOUNT'), r.data) def test_logout(self): self.authenticate() From adb26802899e33f6cd878b240d24389a5e4e76a0 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 1 Feb 2013 18:21:43 -0500 Subject: [PATCH 33/33] Add change password endpoint --- CHANGES | 4 +++- flask_security/datastore.py | 4 ++-- tests/configured_tests.py | 10 +++++----- tests/functional_tests.py | 8 ++++---- tests/signals_tests.py | 3 ++- tests/unit_tests.py | 4 ++-- 6 files changed, 18 insertions(+), 15 deletions(-) diff --git a/CHANGES b/CHANGES index 0bb5452..9f289ca 100644 --- a/CHANGES +++ b/CHANGES @@ -4,7 +4,7 @@ Flask-Security Changelog Here you can see the full list of changes between each Flask-Security release. -Version 1.5.5 +Version 1.6.0 ------------- Not yet released @@ -13,6 +13,8 @@ Not yet released - Flask-Login messages are configurable - AJAX requests must now send a CSRF token for security reasons - Login form messages are now configurable +- Forms can now be extended with more fields +- Added change password endpoint Version 1.5.4 diff --git a/flask_security/datastore.py b/flask_security/datastore.py index d0b3bba..f8c7218 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -81,11 +81,11 @@ class UserDatastore(object): kwargs['roles'] = roles return kwargs - def find_user(self, **kwargs): + def find_user(self, *args, **kwargs): """Returns a user matching the provided parameters.""" raise NotImplementedError - def find_role(self, name): + def find_role(self, *args, **kwargs): """Returns a role matching the provided name.""" raise NotImplementedError diff --git a/tests/configured_tests.py b/tests/configured_tests.py index 0251c57..2ab3b9d 100644 --- a/tests/configured_tests.py +++ b/tests/configured_tests.py @@ -331,7 +331,7 @@ class ChangePasswordTest(SecurityTest): def test_change_password_invalid(self): self.authenticate() - r = self.client.post('/change', data={ + r = self._post('/change', data={ 'password': 'notpassword', 'new_password': 'newpassword', 'new_password_confirm': 'newpassword' @@ -341,7 +341,7 @@ class ChangePasswordTest(SecurityTest): def test_change_password_mismatch(self): self.authenticate() - r = self.client.post('/change', data={ + r = self._post('/change', data={ 'password': 'password', 'new_password': 'newpassword', 'new_password_confirm': 'notnewpassword' @@ -351,7 +351,7 @@ class ChangePasswordTest(SecurityTest): def test_change_password_bad_password(self): self.authenticate() - r = self.client.post('/change', data={ + r = self._post('/change', data={ 'password': 'password', 'new_password': 'a', 'new_password_confirm': 'a' @@ -362,7 +362,7 @@ class ChangePasswordTest(SecurityTest): def test_change_password_success(self): self.authenticate() with self.app.extensions['mail'].record_messages() as outbox: - r = self.client.post('/change', data={ + r = self._post('/change', data={ 'password': 'password', 'new_password': 'newpassword', 'new_password_confirm': 'newpassword' @@ -385,7 +385,7 @@ class ChangePasswordPostViewTest(SecurityTest): def test_change_password_success(self): self.authenticate() - r = self.client.post('/change', data={ + r = self._post('/change', data={ 'password': 'password', 'new_password': 'newpassword', 'new_password_confirm': 'newpassword' diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 1196dfb..2438282 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -224,11 +224,11 @@ class MongoEngineSecurityTests(DefaultSecurityTests): return create_app(auth_config, **kwargs) -class PeeweeSecurityTests(DefaultSecurityTests): +# class PeeweeSecurityTests(DefaultSecurityTests): - def _create_app(self, auth_config, **kwargs): - from tests.test_app.peewee_app import create_app - return create_app(auth_config, **kwargs) +# def _create_app(self, auth_config, **kwargs): +# from tests.test_app.peewee_app import create_app +# return create_app(auth_config, **kwargs) class DefaultDatastoreTests(SecurityTest): diff --git a/tests/signals_tests.py b/tests/signals_tests.py index 3ea8dac..9e7359f 100644 --- a/tests/signals_tests.py +++ b/tests/signals_tests.py @@ -165,7 +165,8 @@ class ChangeableSignalsTests(SecurityTest): client.post('/change', data=dict(password='password', new_password='newpassword', - new_password_confirm='newpassword')) + new_password_confirm='newpassword', + csrf_token=self.csrf_token)) self.assertEqual(mocks.signals_sent(), set([password_changed])) user = self.app.security.datastore.find_user(email='joe@lp.com') calls = mocks[password_changed] diff --git a/tests/unit_tests.py b/tests/unit_tests.py index d08db38..41e4554 100644 --- a/tests/unit_tests.py +++ b/tests/unit_tests.py @@ -55,8 +55,8 @@ class DatastoreTests(unittest.TestCase): self.assertRaises(NotImplementedError, ds.delete, None) def test_unimplemented_user_datastore_methods(self): - self.assertRaises(NotImplementedError, self.ds.find_user) - self.assertRaises(NotImplementedError, self.ds.find_role) + self.assertRaises(NotImplementedError, self.ds.find_user, None) + self.assertRaises(NotImplementedError, self.ds.find_role, None) def test_toggle_active(self): user.active = True