diff --git a/.gitignore b/.gitignore index a9701f2..b04ba2f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ env/ Session.vim .netrwhist *~ + +.eggs/README.txt diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 12ea264..328ced2 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -11,6 +11,7 @@ from flask import current_app as app from werkzeug.local import LocalProxy +from werkzeug.security import safe_str_cmp from .signals import password_reset, reset_password_instructions_sent from .utils import send_mail, md5, encrypt_password, url_for_security, \ @@ -53,7 +54,8 @@ def generate_reset_password_token(user): :param user: The user to work with """ - data = [str(user.id), md5(user.password)] + password_hash = md5(user.password) if user.password else None + data = [str(user.id), password_hash] return _security.reset_serializer.dumps(data) @@ -61,11 +63,19 @@ def reset_password_token_status(token): """Returns the expired status, invalid status, and user of a password reset token. For example:: - expired, invalid, user = reset_password_token_status('...') + expired, invalid, user, data = reset_password_token_status('...') :param token: The password reset token """ - return get_token_status(token, 'reset', 'RESET_PASSWORD') + expired, invalid, user, data = get_token_status(token, 'reset', 'RESET_PASSWORD', + return_data=True) + if not invalid: + if user.password: + password_hash = md5(user.password) + if not safe_str_cmp(password_hash, data[1]): + invalid = True + + return expired, invalid, user def update_password(user, password): diff --git a/flask_security/utils.py b/flask_security/utils.py index f0f8c27..ccd5b0b 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -341,7 +341,7 @@ def send_mail(subject, recipient, template, **context): mail.send(msg) -def get_token_status(token, serializer, max_age=None): +def get_token_status(token, serializer, max_age=None, return_data=False): """Get the status of a token. :param token: The token to check @@ -367,7 +367,11 @@ def get_token_status(token, serializer, max_age=None): user = _datastore.find_user(id=data[0]) expired = expired and (user is not None) - return expired, invalid, user + + if return_data: + return expired, invalid, user, data + else: + return expired, invalid, user def get_identity_attributes(app=None): diff --git a/tests/test_recoverable.py b/tests/test_recoverable.py index 8b91ece..8fdbe01 100644 --- a/tests/test_recoverable.py +++ b/tests/test_recoverable.py @@ -122,6 +122,47 @@ def test_expired_reset_token(client, get_message): assert msg in response.data +def test_used_reset_token(client, get_message): + with capture_reset_password_requests() as requests: + client.post('/reset', data=dict(email='joe@lp.com'), follow_redirects=True) + + token = requests[0]['token'] + + # use the token + response = client.post('/reset/' + token, data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + + assert get_message('PASSWORD_RESET') in response.data + + logout(client) + + # attempt to use it a second time + response2 = client.post('/reset/' + token, data={ + 'password': 'otherpassword', + 'password_confirm': 'otherpassword' + }, follow_redirects=True) + + msg = get_message('INVALID_RESET_PASSWORD_TOKEN') + assert msg in response2.data + + +def test_reset_passwordless_user(client, get_message): + with capture_reset_password_requests() as requests: + client.post('/reset', data=dict(email='jess@lp.com'), follow_redirects=True) + + token = requests[0]['token'] + + # use the token + response = client.post('/reset/' + token, data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + + assert get_message('PASSWORD_RESET') in response.data + + @pytest.mark.settings(reset_url='/custom_reset') def test_custom_reset_url(client): response = client.get('/custom_reset')