Add a password-changed signal

This commit is contained in:
Eskil Heyn Olsen
2013-01-12 19:03:02 -08:00
parent 508f4d1b52
commit ded62a556b
11 changed files with 140 additions and 7 deletions
+9 -2
View File
@@ -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/
+5
View File
@@ -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`
+45
View File
@@ -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())
+1
View File
@@ -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'
}
+2
View File
@@ -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")
@@ -0,0 +1,4 @@
<p>Your password has been changed.</p>
{% if security.recoverable %}
<p>If you did not change your password, <a href="{{ url_for_security('forgot_password') }}">click here to reset your password</a>.</p>
{% endif %}
@@ -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 %}
+3 -2
View File
@@ -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])
+3 -2
View File
@@ -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'))
+10
View File
@@ -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={
+53 -1
View File
@@ -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 = {