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/docs/api.rst b/docs/api.rst index c4ff952..e186524 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -87,7 +87,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 @@ -95,6 +101,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 e4e4836..f05ee39 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 @@ -94,6 +96,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 @@ -112,6 +115,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 7a62699..3f2008a 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,12 +66,14 @@ _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', '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' } @@ -97,6 +102,8 @@ _default_messages = { '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'), + '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'), } @@ -118,6 +125,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, } @@ -290,6 +298,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) @@ -319,8 +330,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. @@ -344,6 +355,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 ccdfc08..c471605 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) @@ -95,7 +96,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) @@ -196,3 +198,28 @@ class ResetPasswordForm(Form, NewPasswordFormMixin, PasswordConfirmFormMixin): """The default reset password form""" submit = SubmitField("Reset Password") + + +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): + if not super(ChangePasswordForm, self).validate(): + return False + + 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/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/_menu.html b/flask_security/templates/security/_menu.html index d50b5a4..5291f80 100644 --- a/flask_security/templates/security/_menu.html +++ b/flask_security/templates/security/_menu.html @@ -12,4 +12,4 @@
  • Confirm account
  • {% endif %} -{% endif %} \ No newline at end of file +{% endif %} diff --git a/flask_security/templates/security/change_password.html b/flask_security/templates/security/change_password.html new file mode 100644 index 0000000..8ee3eb7 --- /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) }} +
    + 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..d1224cf --- /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 it.

    +{% 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..e74bd80 --- /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 it. +{{ url_for_security('forgot_password', _external=True) }} +{% endif %} diff --git a/flask_security/utils.py b/flask_security/utils.py index 91bd65f..66d0f57 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']) @@ -373,6 +373,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 3efd316..dfc87fd 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_login import current_user from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy @@ -21,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 @@ -278,6 +280,33 @@ 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(): + after_this_request(_commit) + 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) + + 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 +340,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 5e9517a..0251c57 100644 --- a/tests/configured_tests.py +++ b/tests/configured_tests.py @@ -317,6 +317,95 @@ class ExpiredResetPasswordTest(SecurityTest): self.assertIn('You did not reset your password within', r.data) +class ChangePasswordTest(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True, + 'SECURITY_CHANGEABLE': True, + } + + def test_change_password(self): + self.authenticate() + 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) + self.assertNotIn('You successfully changed your password', r.data) + 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.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() + 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 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 = { diff --git a/tests/signals_tests.py b/tests/signals_tests.py index 35f067d..3ea8dac 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 @@ -151,6 +152,56 @@ 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')) + 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 = {