From 897b2fceababb6cccfcf6f34adfc2d3265a17437 Mon Sep 17 00:00:00 2001 From: waltaskew Date: Wed, 1 Oct 2014 15:59:28 -0400 Subject: [PATCH 01/24] Add configuration for token expiration --- AUTHORS | 1 + docs/configuration.rst | 4 ++++ flask_security/core.py | 3 ++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index e098338d..1aaa781e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -36,3 +36,4 @@ Rotem Yaari Srijan Choudhary Tristan Escalada Vadim Kotov +Walt Askew diff --git a/docs/configuration.rst b/docs/configuration.rst index 018d4ae9..0a9bf69b 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -37,6 +37,10 @@ Core ``SECURITY_TOKEN_AUTHENTICATION_HEADER`` Specifies the HTTP header to read when using token authentication. Defaults to ``Authentication-Token``. +``SECURITY_TOKEN_MAX_AGE`` Specifies the number of seconds before + an authentication token expires. + Defaults to None, meaning the token + never expires. ``SECURITY_DEFAULT_HTTP_AUTH_REALM`` Specifies the default authentication realm when using basic HTTP auth. Defaults to ``Login Required`` diff --git a/flask_security/core.py b/flask_security/core.py index fb2a6b67..d29149c5 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -75,6 +75,7 @@ 'EMAIL_SENDER': 'no-reply@localhost', 'TOKEN_AUTHENTICATION_KEY': 'auth_token', 'TOKEN_AUTHENTICATION_HEADER': 'Authentication-Token', + 'TOKEN_MAX_AGE': None, 'CONFIRM_SALT': 'confirm-salt', 'RESET_SALT': 'reset-salt', 'LOGIN_SALT': 'login-salt', @@ -192,7 +193,7 @@ def _user_loader(user_id): def _token_loader(token): try: - data = _security.remember_token_serializer.loads(token) + data = _security.remember_token_serializer.loads(token, max_age=_security.token_max_age) user = _security.datastore.find_user(id=data[0]) if user and safe_str_cmp(md5(user.password), data[1]): return user From 8c45271bf9b9ddfaff27f2160ca1338db353b26c Mon Sep 17 00:00:00 2001 From: Antoine Bertin Date: Tue, 21 Oct 2014 10:27:17 +0200 Subject: [PATCH 02/24] Fix RemoveRoleCommand docstring --- flask_security/script.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/script.py b/flask_security/script.py index a9c80842..cccbba79 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -100,7 +100,7 @@ def run(self, user_identifier, role_name): class RemoveRoleCommand(_RoleCommand): - """Add a role to a user""" + """Remove a role from a user""" @commit def run(self, user_identifier, role_name): From 6cfe662dc666059d3393cf7d2b8aa85fd5bb009b Mon Sep 17 00:00:00 2001 From: Antoine Bertin Date: Tue, 21 Oct 2014 11:26:17 +0200 Subject: [PATCH 03/24] Fix ActivateUserCommand docstring --- flask_security/script.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/script.py b/flask_security/script.py index a9c80842..42051d37 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -124,7 +124,7 @@ def run(self, user_identifier): class ActivateUserCommand(_ToggleActiveCommand): - """Deactive a user""" + """Activate a user""" @commit def run(self, user_identifier): From 7e4fc94601d4f3545b1d221a8152842441183961 Mon Sep 17 00:00:00 2001 From: Alex Eftimie Date: Wed, 19 Nov 2014 14:11:58 +0200 Subject: [PATCH 04/24] Fail silently for get_user(None) get_user(identifier) checks if the identifier is a number by trying to convert it to int. This works for strings, but in a particular case, when identifier is None, it fails. Checking for both TypeError and ValueError fixes it. --- 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 aca5d50c..3d009ad9 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -195,7 +195,7 @@ def get_user(self, identifier): def _is_numeric(self, value): try: int(value) - except ValueError: + except (TypeError, ValueError): return False return True From 4d70f016ad8f4cd153b046b00184dea576205650 Mon Sep 17 00:00:00 2001 From: Jeremy Epstein Date: Fri, 28 Nov 2014 10:36:31 +1100 Subject: [PATCH 05/24] re #343: Add slash before or after token in flask-security URLs correctly --- flask_security/utils.py | 6 ++++++ flask_security/views.py | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/flask_security/utils.py b/flask_security/utils.py index 86bb24a6..b1996d8b 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -188,6 +188,12 @@ def get_url(endpoint_or_url): return endpoint_or_url +def slash_url_suffix(url, suffix): + """Adds a slash either to the beginning or the end of a suffix (which is to be appended to a URL), depending on whether or not the URL ends with a slash.""" + + return url.endswith('/') and ('%s/' % suffix) or ('/%s' % suffix) + + def get_security_endpoint_name(endpoint): return '%s.%s' % (_security.blueprint_name, endpoint) diff --git a/flask_security/views.py b/flask_security/views.py index 864c9e30..7ec0cacd 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -26,7 +26,7 @@ from .registerable import register_user from .utils import config_value, do_flash, get_url, get_post_login_redirect, \ get_post_register_redirect, get_message, login_user, logout_user, \ - url_for_security as url_for + url_for_security as url_for, slash_url_suffix # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) @@ -333,7 +333,7 @@ def create_blueprint(state, import_name): bp.route(state.login_url, methods=['GET', 'POST'], endpoint='login')(send_login) - bp.route(state.login_url + '/', + bp.route(state.login_url + slash_url_suffix(state.login_url, ''), endpoint='token_login')(token_login) else: bp.route(state.login_url, @@ -349,7 +349,7 @@ def create_blueprint(state, import_name): bp.route(state.reset_url, methods=['GET', 'POST'], endpoint='forgot_password')(forgot_password) - bp.route(state.reset_url + '/', + bp.route(state.reset_url + slash_url_suffix(state.reset_url, ''), methods=['GET', 'POST'], endpoint='reset_password')(reset_password) @@ -362,7 +362,7 @@ def create_blueprint(state, import_name): bp.route(state.confirm_url, methods=['GET', 'POST'], endpoint='send_confirmation')(send_confirmation) - bp.route(state.confirm_url + '/', + bp.route(state.confirm_url + slash_url_suffix(state.confirm_url, ''), methods=['GET', 'POST'], endpoint='confirm_email')(confirm_email) From 665b164618c40be47eda8da46b9a055fbd3b1a30 Mon Sep 17 00:00:00 2001 From: Jeremy Epstein Date: Fri, 28 Nov 2014 13:50:25 +1100 Subject: [PATCH 06/24] split docstring into multiple lines to make travis CI happy --- flask_security/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flask_security/utils.py b/flask_security/utils.py index b1996d8b..3ec962ca 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -189,7 +189,9 @@ def get_url(endpoint_or_url): def slash_url_suffix(url, suffix): - """Adds a slash either to the beginning or the end of a suffix (which is to be appended to a URL), depending on whether or not the URL ends with a slash.""" + """Adds a slash either to the beginning or the end of a suffix + (which is to be appended to a URL), depending on whether or not + the URL ends with a slash.""" return url.endswith('/') and ('%s/' % suffix) or ('/%s' % suffix) From 923ad720a19e8983e6124bb459ad9e0453c66e15 Mon Sep 17 00:00:00 2001 From: "Stephen J. Fuhry" Date: Sun, 28 Dec 2014 08:25:57 -0500 Subject: [PATCH 07/24] X-Forwarded-For can contain multiple IP addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the nginx docs: http://nginx.org/en/docs/http/ngx_http_proxy_module.html > $proxy_add_x_forwarded_for > the “X-Forwarded-For” client request header field with the $remote_addr > variable appended to it, separated by a comma. If the “X-Forwarded-For” > field is not present in the client request header, the > $proxy_add_x_forwarded_for variable is equal to the $remote_addr > variable. Use the last IP address in X-Forwarded-For. For this to work properly behind a trusted proxy, you must be using ProxyFix as described in the flask & werkzeug documentation. --- .gitignore | 8 ++++++++ docs/configuration.rst | 3 ++- flask_security/utils.py | 6 +++--- tests/test_trackable.py | 16 ++++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 4d3ab557..a9701f21 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,11 @@ env/ *.db *cache* + +# vim +[._]*.s[a-w][a-z] +[._]s[a-w][a-z] +*.un~ +Session.vim +.netrwhist +*~ diff --git a/docs/configuration.rst b/docs/configuration.rst index 018d4ae9..b6b994f6 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -159,7 +159,8 @@ Feature Flags option. Defaults to ``False``. ``SECURITY_TRACKABLE`` Specifies if Flask-Security should track basic user login statistics. If set to ``True``, ensure your - models have the required fields/attribues. Defaults to + models have the required fields/attribues. Be sure to + use `ProxyFix ` if you are using a proxy. Defaults to ``False`` ``SECURITY_PASSWORDLESS`` Specifies if Flask-Security should enable the passwordless login feature. If set to ``True``, users diff --git a/flask_security/utils.py b/flask_security/utils.py index 86bb24a6..0419ba04 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -62,10 +62,10 @@ def login_user(user, remember=None): return False if _security.trackable: - if 'X-Forwarded-For' not in request.headers: - remote_addr = request.remote_addr or 'untrackable' + if 'X-Forwarded-For' in request.headers: + remote_addr = request.headers.getlist("X-Forwarded-For")[0].rpartition(' ')[-1] else: - remote_addr = request.headers.getlist("X-Forwarded-For")[0] + remote_addr = request.remote_addr or 'untrackable' old_current_login, new_current_login = user.current_login_at, datetime.utcnow() old_current_ip, new_current_ip = user.current_login_ip, remote_addr diff --git a/tests/test_trackable.py b/tests/test_trackable.py index 1e08662d..70177542 100644 --- a/tests/test_trackable.py +++ b/tests/test_trackable.py @@ -26,3 +26,19 @@ def test_trackable_flag(app, client): assert user.last_login_ip == 'untrackable' assert user.current_login_ip == '127.0.0.1' assert user.login_count == 2 + + +def test_trackable_with_multiple_ips_in_headers(app, client): + e = 'matt@lp.com' + authenticate(client, email=e) + logout(client) + authenticate(client, email=e, headers={ + 'X-Forwarded-For': '99.99.99.99, 88.88.88.88'}) + + with app.app_context(): + user = app.security.datastore.find_user(email=e) + assert user.last_login_at is not None + assert user.current_login_at is not None + assert user.last_login_ip == 'untrackable' + assert user.current_login_ip == '88.88.88.88' + assert user.login_count == 2 From 3681823fcfbfbfb0076efca889dd29affa72475a Mon Sep 17 00:00:00 2001 From: Nuno Santos Date: Fri, 30 Jan 2015 11:27:53 +0100 Subject: [PATCH 08/24] Include WWW-Authenticate headers in @auth_required. When using @http_auth_required, the WWW-Authenticate header is included, but when using @auth_required('basic'), it is not. This change includes that header in every @auth_required call that contains the 'basic' method. --- flask_security/decorators.py | 10 +++++++--- tests/test_common.py | 13 +++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 3363fc59..82025aef 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -137,11 +137,15 @@ def dashboard(): def wrapper(fn): @wraps(fn) def decorated_view(*args, **kwargs): - mechanisms = [login_mechanisms.get(method) for method in auth_methods] - for mechanism in mechanisms: + h = {} + mechanisms = [(method, login_mechanisms.get(method)) for method in auth_methods] + for method, mechanism in mechanisms: if mechanism and mechanism(): return fn(*args, **kwargs) - return _get_unauthorized_response() + elif method == 'basic': + r = _security.default_http_auth_realm + h['WWW-Authenticate'] = 'Basic realm="%s"' % r + return _get_unauthorized_response(headers=h) return decorated_view return wrapper diff --git a/tests/test_common.py b/tests/test_common.py index b91c5e4a..e884ab5f 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -226,6 +226,19 @@ def test_multi_auth_basic(client): assert response.status_code == 401 +def test_multi_auth_basic_invalid(client): + response = client.get('/multi_auth', headers={ + 'Authorization': 'Basic %s' % base64.b64encode(b"bogus:bogus").decode('utf-8') + }) + assert b'

Unauthorized

' in response.data + assert 'WWW-Authenticate' in response.headers + assert 'Basic realm="Login Required"' == response.headers['WWW-Authenticate'] + + response = client.get('/multi_auth') + print(response.headers) + assert response.status_code == 401 + + def test_multi_auth_token(client): response = json_authenticate(client) token = response.jdata['response']['user']['authentication_token'] From 248ea5d27296698a4241090d18072d015b305287 Mon Sep 17 00:00:00 2001 From: Lars Holm Nielsen Date: Fri, 6 Mar 2015 12:41:15 +0100 Subject: [PATCH 09/24] Custom AnonymousUser support. (addresses #362) --- flask_security/core.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index fb2a6b67..2d19d259 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -198,11 +198,11 @@ def _token_loader(token): return user except: pass - return AnonymousUser() + return _security.login_manager.anonymous_user() def _identity_loader(): - if not isinstance(current_user._get_current_object(), AnonymousUser): + if not isinstance(current_user._get_current_object(), AnonymousUserMixin): identity = Identity(current_user.id) return identity @@ -217,9 +217,9 @@ def _on_identity_loaded(sender, identity): identity.user = current_user -def _get_login_manager(app): +def _get_login_manager(app, anonymous_user): lm = LoginManager() - lm.anonymous_user = AnonymousUser + lm.anonymous_user = anonymous_user or AnonymousUser lm.login_view = '%s.login' % cv('BLUEPRINT_NAME', app=app) lm.user_loader(_user_loader) lm.token_loader(_token_loader) @@ -257,14 +257,14 @@ def _get_serializer(app, name): return URLSafeTimedSerializer(secret_key=secret_key, salt=salt) -def _get_state(app, datastore, **kwargs): +def _get_state(app, datastore, anonymous_user=None, **kwargs): for key, value in get_config(app).items(): kwargs[key.lower()] = value kwargs.update(dict( app=app, datastore=datastore, - login_manager=_get_login_manager(app), + login_manager=_get_login_manager(app, anonymous_user), principal=_get_principal(app), pwd_context=_get_pwd_context(app), remember_token_serializer=_get_serializer(app, 'remember'), @@ -398,7 +398,8 @@ 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, change_password_form=None, - send_confirmation_form=None, passwordless_login_form=None): + send_confirmation_form=None, passwordless_login_form=None, + anonymous_user=None): """Initializes the Flask-Security extension for the specified application and datastore implentation. @@ -424,7 +425,8 @@ def init_app(self, app, datastore=None, register_blueprint=True, reset_password_form=reset_password_form, change_password_form=change_password_form, send_confirmation_form=send_confirmation_form, - passwordless_login_form=passwordless_login_form) + passwordless_login_form=passwordless_login_form, + anonymous_user=anonymous_user) if register_blueprint: app.register_blueprint(create_blueprint(state, __name__)) From a4581681e52ea63401eff2f55b41da7770edb79d Mon Sep 17 00:00:00 2001 From: Lars Holm Nielsen Date: Fri, 6 Mar 2015 13:09:05 +0100 Subject: [PATCH 10/24] Fix PEP8 error. --- flask_security/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 77e80543..f4c0cd1a 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -10,8 +10,6 @@ :license: MIT, see LICENSE for more details. """ -__version__ = '1.7.4' - from .core import Security, RoleMixin, UserMixin, AnonymousUser, current_user from .datastore import SQLAlchemyUserDatastore, MongoEngineUserDatastore, PeeweeUserDatastore from .decorators import auth_token_required, http_auth_required, \ @@ -21,3 +19,5 @@ from .signals import confirm_instructions_sent, password_reset, \ reset_password_instructions_sent, user_confirmed, user_registered from .utils import login_user, logout_user, url_for_security + +__version__ = '1.7.4' From 4659d10c5c208aed45059a0161db41dc3ca13398 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sat, 2 May 2015 12:11:05 -0400 Subject: [PATCH 11/24] forgot password endpoint should be for anonymous users only. Fixes #291 --- flask_security/views.py | 1 + tests/test_context_processors.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/flask_security/views.py b/flask_security/views.py index 864c9e30..0f6d57cd 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -238,6 +238,7 @@ def confirm_email(token): get_url(_security.post_login_view)) +@anonymous_user_required def forgot_password(): """View function that handles a forgotten password request.""" diff --git a/tests/test_context_processors.py b/tests/test_context_processors.py index 1bb64d25..cec88c27 100644 --- a/tests/test_context_processors.py +++ b/tests/test_context_processors.py @@ -81,6 +81,8 @@ def send_confirmation(): def mail(): return {'foo': 'bar'} + client.get('/logout') + with app.mail.record_messages() as outbox: client.post('/reset', data=dict(email='matt@lp.com')) From bc1f5dd7f9720ea03396553c07e924f6c90c0af4 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sat, 2 May 2015 12:59:02 -0400 Subject: [PATCH 12/24] Stricter tests for signals and a small docs update. Fixes #308 --- docs/api.rst | 33 ++++++++++++++------------------- flask_security/changeable.py | 3 ++- flask_security/passwordless.py | 3 +-- flask_security/recoverable.py | 3 +-- tests/test_changeable.py | 4 ++++ tests/test_confirmable.py | 6 ++++++ tests/test_passwordless.py | 7 ++++++- tests/test_recoverable.py | 7 ++++++- tests/test_registerable.py | 5 +++++ 9 files changed, 45 insertions(+), 26 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 53dcd68f..902f8a0f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -87,43 +87,38 @@ sends the following signals. .. data:: user_registered - Sent when a user registers on the site. It is passed a dict with - the `user` and `confirm_token`, the user being logged in and the - (if so configured) the confirmation token issued. + Sent when a user registers on the site. In addition to the app (which is the + sender), it is passed `user` and `confirm_token` arguments. .. data:: user_confirmed - Sent when a user is confirmed. It is passed `user`, which is the - user being confirmed. + Sent when a user is confirmed. In addition to the app (which is the + sender), it is passed a `user` argument. .. data:: confirm_instructions_sent - Sent when a user requests confirmation instructions. It is passed - the `user`. + Sent when a user requests confirmation instructions. In addition to the app + (which is the sender), it is passed a `user` argument. .. data:: login_instructions_sent - Sent when passwordless login is used and user logs in. It is passed - a dict with the `user` and `login_token`, the user being logged in - and the (if so configured) the login token issued. + Sent when passwordless login is used and user logs in. In addition to the app + (which is the sender), it is passed `user` and `login_token` arguments. .. data:: password_reset - Sent when a user completes a password reset. It is passed the - `user`. + Sent when a user completes a password reset. In addition to the app (which is + the sender), it is passed a `user` argument. .. data:: password_changed - Sent when a user completes a password change. It is passed the - `user`. + Sent when a user completes a password change. In addition to the app (which is + the sender), it is passed a `user` argument. .. data:: reset_password_instructions_sent - Sent when a user requests a password reset. It is passed a dict - with the `user` and `token`, the user being logged in and - the (if so configured) the reset token issued. + Sent when a user requests a password reset. In addition to the app (which is + the sender), it is passed `user` and `token` arguments. -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/flask_security/changeable.py b/flask_security/changeable.py index 6918cc07..c79f84e2 100644 --- a/flask_security/changeable.py +++ b/flask_security/changeable.py @@ -42,4 +42,5 @@ def change_user_password(user, password): user.password = encrypt_password(password) _datastore.put(user) send_password_changed_notice(user) - password_changed.send(app._get_current_object(), user=user) + password_changed.send(app._get_current_object(), + user=user._get_current_object()) diff --git a/flask_security/passwordless.py b/flask_security/passwordless.py index dd6465c6..387b7f20 100644 --- a/flask_security/passwordless.py +++ b/flask_security/passwordless.py @@ -35,8 +35,7 @@ def send_login_instructions(user): send_mail(config_value('EMAIL_SUBJECT_PASSWORDLESS'), user.email, 'login_instructions', user=user, login_link=login_link) - login_instructions_sent.send(app._get_current_object(), - user=user, login_token=token) + login_instructions_sent.send(app._get_current_object(), user=user, login_token=token) def generate_login_token(user): diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index eca50305..1bb68786 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -35,8 +35,7 @@ def send_reset_password_instructions(user): 'reset_instructions', user=user, reset_link=reset_link) - reset_password_instructions_sent.send(app._get_current_object(), - user=user, token=token) + reset_password_instructions_sent.send(app._get_current_object(), user=user, token=token) def send_password_reset_notice(user): diff --git a/tests/test_changeable.py b/tests/test_changeable.py index e63d2b27..40202264 100644 --- a/tests/test_changeable.py +++ b/tests/test_changeable.py @@ -8,6 +8,8 @@ import pytest +from flask import Flask +from flask_security.core import UserMixin from flask_security.signals import password_changed from utils import authenticate @@ -20,6 +22,8 @@ def test_recoverable_flag(app, client, get_message): @password_changed.connect_via(app) def on_password_changed(app, user): + assert isinstance(app, Flask) + assert isinstance(user, UserMixin) recorded.append(user) authenticate(client) diff --git a/tests/test_confirmable.py b/tests/test_confirmable.py index 1a06919e..51116c41 100644 --- a/tests/test_confirmable.py +++ b/tests/test_confirmable.py @@ -10,6 +10,8 @@ import pytest +from flask import Flask +from flask_security.core import UserMixin from flask_security.signals import user_confirmed, confirm_instructions_sent from flask_security.utils import capture_registrations @@ -25,10 +27,14 @@ def test_confirmable_flag(app, client, sqlalchemy_datastore, get_message): @user_confirmed.connect_via(app) def on_confirmed(app, user): + assert isinstance(app, Flask) + assert isinstance(user, UserMixin) recorded_confirms.append(user) @confirm_instructions_sent.connect_via(app) def on_instructions_sent(app, user): + assert isinstance(app, Flask) + assert isinstance(user, UserMixin) recorded_instructions_sent.append(user) # Test login before confirmation diff --git a/tests/test_passwordless.py b/tests/test_passwordless.py index eddf4794..10214127 100644 --- a/tests/test_passwordless.py +++ b/tests/test_passwordless.py @@ -10,8 +10,10 @@ import pytest +from flask import Flask +from flask_security.core import UserMixin from flask_security.signals import login_instructions_sent -from flask_security.utils import capture_passwordless_login_requests +from flask_security.utils import capture_passwordless_login_requests, string_types from utils import logout @@ -23,6 +25,9 @@ def test_trackable_flag(app, client, get_message): @login_instructions_sent.connect_via(app) def on_instructions_sent(app, user, login_token): + assert isinstance(app, Flask) + assert isinstance(user, UserMixin) + assert isinstance(login_token, string_types) recorded.append(user) # Test disabled account diff --git a/tests/test_recoverable.py b/tests/test_recoverable.py index de278e1f..8b91eceb 100644 --- a/tests/test_recoverable.py +++ b/tests/test_recoverable.py @@ -10,8 +10,10 @@ import pytest +from flask import Flask +from flask_security.core import UserMixin from flask_security.signals import reset_password_instructions_sent, password_reset -from flask_security.utils import capture_reset_password_requests +from flask_security.utils import capture_reset_password_requests, string_types from utils import authenticate, logout @@ -28,6 +30,9 @@ def on_password_reset(app, user): @reset_password_instructions_sent.connect_via(app) def on_instructions_sent(app, user, token): + assert isinstance(app, Flask) + assert isinstance(user, UserMixin) + assert isinstance(token, string_types) recorded_instructions_sent.append(user) # Test the reset view diff --git a/tests/test_registerable.py b/tests/test_registerable.py index 9510c5b0..6f7d4762 100644 --- a/tests/test_registerable.py +++ b/tests/test_registerable.py @@ -8,6 +8,8 @@ import pytest +from flask import Flask +from flask_security.core import UserMixin from flask_security.signals import user_registered from utils import authenticate, logout @@ -26,6 +28,9 @@ def test_registerable_flag(client, app, get_message): # Test registering is successful, sends email, and fires signal @user_registered.connect_via(app) def on_user_registerd(app, user, confirm_token): + assert isinstance(app, Flask) + assert isinstance(user, UserMixin) + assert confirm_token is None recorded.append(user) data = dict( From 916f5ee01293c314246a96cb072ec7e130281eac Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sat, 2 May 2015 13:05:46 -0400 Subject: [PATCH 13/24] Use StringField instead of TextField. Fixes #312 --- docs/customizing.rst | 4 ++-- flask_security/forms.py | 10 +++++----- tests/test_misc.py | 19 ++++++++++--------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/docs/customizing.rst b/docs/customizing.rst index a81942e6..560f3d8e 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -73,8 +73,8 @@ register form or override validators:: from flask_security.forms import RegisterForm class ExtendedRegisterForm(RegisterForm): - first_name = TextField('First Name', [Required()]) - last_name = TextField('Last Name', [Required()]) + first_name = StringField('First Name', [Required()]) + last_name = StringField('Last Name', [Required()]) security = Security(app, user_datastore, register_form=ExtendedRegisterForm) diff --git a/flask_security/forms.py b/flask_security/forms.py index e3f6a3bd..d5f15b90 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -13,7 +13,7 @@ from flask import request, current_app, flash from flask_wtf import Form as BaseForm -from wtforms import TextField, PasswordField, validators, \ +from wtforms import StringField, PasswordField, validators, \ SubmitField, HiddenField, BooleanField, ValidationError, Field from flask_login import current_user from werkzeug.local import LocalProxy @@ -94,20 +94,20 @@ def __init__(self, *args, **kwargs): class EmailFormMixin(): - email = TextField( + email = StringField( get_form_field_label('email'), validators=[email_required, email_validator]) class UserEmailFormMixin(): user = None - email = TextField( + email = StringField( get_form_field_label('email'), validators=[email_required, email_validator, valid_user_email]) class UniqueEmailFormMixin(): - email = TextField( + email = StringField( get_form_field_label('email'), validators=[email_required, email_validator, unique_user_email]) @@ -204,7 +204,7 @@ def validate(self): class LoginForm(Form, NextFormMixin): """The default login form""" - email = TextField(get_form_field_label('email')) + email = StringField(get_form_field_label('email')) password = PasswordField(get_form_field_label('password')) remember = BooleanField(get_form_field_label('remember_me')) submit = SubmitField(get_form_field_label('login')) diff --git a/tests/test_misc.py b/tests/test_misc.py index 96111aa1..30b0fd04 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -11,7 +11,8 @@ from flask_security import Security from flask_security.forms import LoginForm, RegisterForm, ConfirmRegisterForm, \ SendConfirmationForm, PasswordlessLoginForm, ForgotPasswordForm, ResetPasswordForm, \ - ChangePasswordForm, TextField, PasswordField, email_required, email_validator, valid_user_email + ChangePasswordForm, StringField, PasswordField, email_required, email_validator, \ + valid_user_email from flask_security.utils import capture_reset_password_requests, md5, string_types from utils import authenticate, init_app_with_options, populate_data @@ -41,17 +42,17 @@ def test_register_blueprint_flag(app, sqlalchemy_datastore): @pytest.mark.changeable() def test_basic_custom_forms(app, sqlalchemy_datastore): class MyLoginForm(LoginForm): - email = TextField('My Login Email Address Field') + email = StringField('My Login Email Address Field') class MyRegisterForm(RegisterForm): - email = TextField('My Register Email Address Field') + email = StringField('My Register Email Address Field') class MyForgotPasswordForm(ForgotPasswordForm): - email = TextField('My Forgot Email Address Field', - validators=[email_required, email_validator, valid_user_email]) + email = StringField('My Forgot Email Address Field', + validators=[email_required, email_validator, valid_user_email]) class MyResetPasswordForm(ResetPasswordForm): - password = TextField('My Reset Password Field') + password = StringField('My Reset Password Field') class MyChangePasswordForm(ChangePasswordForm): password = PasswordField('My Change Password Field') @@ -96,10 +97,10 @@ def test_confirmable_custom_form(app, sqlalchemy_datastore): app.config['SECURITY_CONFIRMABLE'] = True class MyRegisterForm(ConfirmRegisterForm): - email = TextField('My Register Email Address Field') + email = StringField('My Register Email Address Field') class MySendConfirmationForm(SendConfirmationForm): - email = TextField('My Send Confirmation Email Address Field') + email = StringField('My Send Confirmation Email Address Field') app.security = Security(app, datastore=sqlalchemy_datastore, @@ -119,7 +120,7 @@ def test_passwordless_custom_form(app, sqlalchemy_datastore): app.config['SECURITY_PASSWORDLESS'] = True class MyPasswordlessLoginForm(PasswordlessLoginForm): - email = TextField('My Passwordless Email Address Field') + email = StringField('My Passwordless Email Address Field') app.security = Security(app, datastore=sqlalchemy_datastore, From f2a5e4b614c6bf17797f291dc582149ef9fc85c6 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sat, 2 May 2015 13:25:26 -0400 Subject: [PATCH 14/24] Normalize import paths. Fixes #313 --- flask_security/__init__.py | 4 ++-- flask_security/changeable.py | 4 ++-- flask_security/confirmable.py | 4 ++-- flask_security/core.py | 8 ++++---- flask_security/datastore.py | 4 ++-- flask_security/decorators.py | 8 ++++---- flask_security/forms.py | 4 ++-- flask_security/passwordless.py | 4 ++-- flask_security/recoverable.py | 4 ++-- flask_security/registerable.py | 4 ++-- flask_security/script.py | 6 +++--- flask_security/signals.py | 4 ++-- flask_security/utils.py | 10 +++++----- flask_security/views.py | 4 ++-- 14 files changed, 36 insertions(+), 36 deletions(-) diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 77e80543..647d1f07 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security - ~~~~~~~~~~~~~~~~~~ + flask_security + ~~~~~~~~~~~~~~ Flask-Security is a Flask extension that aims to add quick and simple security via Flask-Login, Flask-Principal, Flask-WTF, and passlib. diff --git a/flask_security/changeable.py b/flask_security/changeable.py index c79f84e2..c968f2e7 100644 --- a/flask_security/changeable.py +++ b/flask_security/changeable.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.changeable - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.changeable + ~~~~~~~~~~~~~~~~~~~~~~~~~ Flask-Security recoverable module diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 8d19ae83..aed2d238 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.confirmable - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.confirmable + ~~~~~~~~~~~~~~~~~~~~~~~~~~ Flask-Security confirmable module diff --git a/flask_security/core.py b/flask_security/core.py index fb2a6b67..286d1061 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.core - ~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.core + ~~~~~~~~~~~~~~~~~~~ Flask-Security core module @@ -10,9 +10,9 @@ """ from flask import current_app, render_template -from flask.ext.login import AnonymousUserMixin, UserMixin as BaseUserMixin, \ +from flask_login import AnonymousUserMixin, UserMixin as BaseUserMixin, \ LoginManager, current_user -from flask.ext.principal import Principal, RoleNeed, UserNeed, Identity, \ +from flask_principal import Principal, RoleNeed, UserNeed, Identity, \ identity_loaded from itsdangerous import URLSafeTimedSerializer from passlib.context import CryptContext diff --git a/flask_security/datastore.py b/flask_security/datastore.py index aca5d50c..c66a63e5 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.datastore - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.datastore + ~~~~~~~~~~~~~~~~~~~~~~~~ This module contains an user datastore classes. diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 3363fc59..a928e04d 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.decorators - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.decorators + ~~~~~~~~~~~~~~~~~~~~~~~~~ Flask-Security decorators module @@ -13,8 +13,8 @@ from functools import wraps from flask import current_app, Response, request, redirect, _request_ctx_stack -from flask.ext.login import current_user, login_required # pragma: no flakes -from flask.ext.principal import RoleNeed, Permission, Identity, identity_changed +from flask_login import current_user, login_required # pragma: no flakes +from flask_principal import RoleNeed, Permission, Identity, identity_changed from werkzeug.local import LocalProxy from . import utils diff --git a/flask_security/forms.py b/flask_security/forms.py index d5f15b90..62bbfda7 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.forms - ~~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.forms + ~~~~~~~~~~~~~~~~~~~~ Flask-Security forms module diff --git a/flask_security/passwordless.py b/flask_security/passwordless.py index 387b7f20..2d375d89 100644 --- a/flask_security/passwordless.py +++ b/flask_security/passwordless.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.passwordless - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.passwordless + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Flask-Security passwordless module diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 1bb68786..12ea264d 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.recoverable - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.recoverable + ~~~~~~~~~~~~~~~~~~~~~~~~~~ Flask-Security recoverable module diff --git a/flask_security/registerable.py b/flask_security/registerable.py index e4a7e782..781afedc 100644 --- a/flask_security/registerable.py +++ b/flask_security/registerable.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.registerable - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.registerable + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Flask-Security registerable module diff --git a/flask_security/script.py b/flask_security/script.py index a9c80842..27ec3efc 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.script - ~~~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.script + ~~~~~~~~~~~~~~~~~~~~~ Flask-Security script module @@ -18,7 +18,7 @@ import re from flask import current_app -from flask.ext.script import Command, Option +from flask_script import Command, Option from werkzeug.local import LocalProxy from .utils import encrypt_password diff --git a/flask_security/signals.py b/flask_security/signals.py index 532bba96..b5b558e7 100644 --- a/flask_security/signals.py +++ b/flask_security/signals.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.signals - ~~~~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.signals + ~~~~~~~~~~~~~~~~~~~~~~ Flask-Security signals module diff --git a/flask_security/utils.py b/flask_security/utils.py index 86bb24a6..fa0b17de 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.utils - ~~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.utils + ~~~~~~~~~~~~~~~~~~~~ Flask-Security utils module @@ -23,9 +23,9 @@ from datetime import datetime, timedelta from flask import url_for, flash, current_app, request, session, render_template -from flask.ext.login import login_user as _login_user, logout_user as _logout_user -from flask.ext.mail import Message -from flask.ext.principal import Identity, AnonymousIdentity, identity_changed +from flask_login import login_user as _login_user, logout_user as _logout_user +from flask_mail import Message +from flask_principal import Identity, AnonymousIdentity, identity_changed from itsdangerous import BadSignature, SignatureExpired from werkzeug.local import LocalProxy diff --git a/flask_security/views.py b/flask_security/views.py index 0f6d57cd..8acb5fd9 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - flask.ext.security.views - ~~~~~~~~~~~~~~~~~~~~~~~~ + flask_security.views + ~~~~~~~~~~~~~~~~~~~~ Flask-Security views module From 9cda8baff3c0124c72608c2f679f84bf04de8d30 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sat, 2 May 2015 13:55:05 -0400 Subject: [PATCH 15/24] Fix #367 --- docs/configuration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index c556c8fe..83627d49 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -31,7 +31,7 @@ Core ``SECURITY_EMAIL_SENDER`` Specifies the email address to send emails as. Defaults to ``no-reply@localhost``. -``SECURITY_TOKEN_AUTHENTICATION_KEY`` Specifies the query sting parameter to +``SECURITY_TOKEN_AUTHENTICATION_KEY`` Specifies the query string parameter to read when using token authentication. Defaults to ``auth_token``. ``SECURITY_TOKEN_AUTHENTICATION_HEADER`` Specifies the HTTP header to read when From 10fd1844d8ace715673cf7c78f1365b1f1bb7307 Mon Sep 17 00:00:00 2001 From: Nuno Santos Date: Wed, 28 May 2014 18:50:31 +0200 Subject: [PATCH 16/24] Allow overriding of unauthorized callback. Related to issue #255. --- flask_security/core.py | 6 +++++- flask_security/decorators.py | 29 ++++++++++++++++++++++------- tests/test_misc.py | 16 ++++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 188c0cb5..02761de8 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -273,7 +273,8 @@ def _get_state(app, datastore, **kwargs): reset_serializer=_get_serializer(app, 'reset'), confirm_serializer=_get_serializer(app, 'confirm'), _context_processors={}, - _send_mail_task=None + _send_mail_task=None, + _unauthorized_callback=None )) for key, value in _default_forms.items(): @@ -381,6 +382,9 @@ def mail_context_processor(self, fn): def send_mail_task(self, fn): self._send_mail_task = fn + def unauthorized_handler(self, fn): + self._unauthorized_callback = fn + class Security(object): """The :class:`Security` class initializes the Flask-Security extension. diff --git a/flask_security/decorators.py b/flask_security/decorators.py index da95e02d..356b5695 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -90,9 +90,12 @@ def decorator(fn): def wrapper(*args, **kwargs): if _check_http_auth(): return fn(*args, **kwargs) - r = _security.default_http_auth_realm if callable(realm) else realm - h = {'WWW-Authenticate': 'Basic realm="%s"' % r} - return _get_unauthorized_response(headers=h) + if _security._unauthorized_callback: + return _security._unauthorized_callback() + else: + r = _security.default_http_auth_realm if callable(realm) else realm + h = {'WWW-Authenticate': 'Basic realm="%s"' % r} + return _get_unauthorized_response(headers=h) return wrapper if callable(realm): @@ -112,7 +115,10 @@ def auth_token_required(fn): def decorated(*args, **kwargs): if _check_token(): return fn(*args, **kwargs) - return _get_unauthorized_response() + if _security._unauthorized_callback: + return _security._unauthorized_callback() + else: + return _get_unauthorized_response() return decorated @@ -145,7 +151,10 @@ def decorated_view(*args, **kwargs): elif method == 'basic': r = _security.default_http_auth_realm h['WWW-Authenticate'] = 'Basic realm="%s"' % r - return _get_unauthorized_response(headers=h) + if _security._unauthorized_callback: + return _security._unauthorized_callback() + else: + return _get_unauthorized_response() return decorated_view return wrapper @@ -170,7 +179,10 @@ def decorated_view(*args, **kwargs): perms = [Permission(RoleNeed(role)) for role in roles] for perm in perms: if not perm.can(): - return _get_unauthorized_view() + if _security._unauthorized_callback: + return _security._unauthorized_callback() + else: + return _get_unauthorized_view() return fn(*args, **kwargs) return decorated_view return wrapper @@ -196,7 +208,10 @@ def decorated_view(*args, **kwargs): perm = Permission(*[RoleNeed(role) for role in roles]) if perm.can(): return fn(*args, **kwargs) - return _get_unauthorized_view() + if _security._unauthorized_callback: + return _security._unauthorized_callback() + else: + return _get_unauthorized_view() return decorated_view return wrapper diff --git a/tests/test_misc.py b/tests/test_misc.py index 30b0fd04..d9314131 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -192,3 +192,19 @@ def test_password_unicode_password_salt(client): assert response.status_code == 302 response = authenticate(client, follow_redirects=True) assert b'Hello matt@lp.com' in response.data + + +def test_set_unauthorized_handler(app, client): + @app.security.unauthorized_handler + def unauthorized(): + app.unauthorized_handler_set = True + return 'unauthorized-handler-set', 401 + + app.unauthorized_handler_set = False + + authenticate(client, "joe@lp.com") + response = client.get("/admin", follow_redirects=True) + + assert app.unauthorized_handler_set is True + assert b'unauthorized-handler-set' in response.data + assert response.status_code == 401 From d08aac6d355199031cc63ddb6eae44db5a33e8c3 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sat, 2 May 2015 14:34:31 -0400 Subject: [PATCH 17/24] Fix pymongo version issue --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index c231d3dd..5a20b643 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,6 +2,7 @@ Flask-SQLAlchemy>=1.0 bcrypt>=1.0.2 flask-mongoengine>=0.7.0 flask-peewee>=0.6.5 +pymongo==2.8 pytest>=2.5.2 pytest-cache>=1.0 pytest-cov>=1.6 From 8a14abaa1e74a5a0e18c3ab53a0eb5f417efd7f3 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sat, 2 May 2015 14:57:34 -0400 Subject: [PATCH 18/24] Fix failing test --- flask_security/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 356b5695..6a011fb9 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -154,7 +154,7 @@ def decorated_view(*args, **kwargs): if _security._unauthorized_callback: return _security._unauthorized_callback() else: - return _get_unauthorized_response() + return _get_unauthorized_response(headers=h) return decorated_view return wrapper From 7884d637c50c72116af962c6f462102976ea63b7 Mon Sep 17 00:00:00 2001 From: Nick Retallack Date: Tue, 7 Apr 2015 16:32:28 -0700 Subject: [PATCH 19/24] prevent password reset from breaking if you have no password If you've just been invited, or are using social auth, you have no password set, so the reset password feature causes a crash. This doesn't need to happen. --- flask_security/recoverable.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 12ea264d..491a9401 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -53,7 +53,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) From a0e203774795f1bad289d76357237fcb604bfa01 Mon Sep 17 00:00:00 2001 From: Nick Retallack Date: Tue, 7 Apr 2015 16:51:49 -0700 Subject: [PATCH 20/24] invalidate password reset tokens when the passwords changes Check that the previous password is the same as it was when this password reset request was generated. --- flask_security/recoverable.py | 10 ++++++++-- flask_security/utils.py | 8 ++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 491a9401..97fa883f 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -62,11 +62,17 @@ 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: + password_hash = md5(user.password) if user.password else None + if 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 f0f8c270..ccd5b0ba 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): From 4411470202bbf0be79cc6911a0f120de7f263022 Mon Sep 17 00:00:00 2001 From: Nick Retallack Date: Mon, 11 May 2015 23:12:05 -0700 Subject: [PATCH 21/24] test: invalidate used password reset tokens Also pep8 compliance and suggested changes. --- flask_security/recoverable.py | 11 +++++++---- tests/test_recoverable.py | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 97fa883f..328ced23 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, \ @@ -66,11 +67,13 @@ def reset_password_token_status(token): :param token: The password reset token """ - expired, invalid, user, data = get_token_status(token, 'reset', 'RESET_PASSWORD', return_data=True) + expired, invalid, user, data = get_token_status(token, 'reset', 'RESET_PASSWORD', + return_data=True) if not invalid: - password_hash = md5(user.password) if user.password else None - if password_hash != data[1]: - invalid = True + if user.password: + password_hash = md5(user.password) + if not safe_str_cmp(password_hash, data[1]): + invalid = True return expired, invalid, user diff --git a/tests/test_recoverable.py b/tests/test_recoverable.py index 8b91eceb..71ada93a 100644 --- a/tests/test_recoverable.py +++ b/tests/test_recoverable.py @@ -122,6 +122,32 @@ 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 + + @pytest.mark.settings(reset_url='/custom_reset') def test_custom_reset_url(client): response = client.get('/custom_reset') From 5697ff80c385a1c5d25bc95627d74068f2faffa2 Mon Sep 17 00:00:00 2001 From: Nick Retallack Date: Mon, 11 May 2015 23:16:04 -0700 Subject: [PATCH 22/24] ignore the eggs readme --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index a9701f21..b04ba2f6 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ env/ Session.vim .netrwhist *~ + +.eggs/README.txt From c10c9050c7f50fc4606b89a3496dd0f252c9e292 Mon Sep 17 00:00:00 2001 From: Nick Retallack Date: Mon, 11 May 2015 23:22:30 -0700 Subject: [PATCH 23/24] test: reset password on a user who has no password The user may have been invited via a social network or an invitation system. --- tests/test_recoverable.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_recoverable.py b/tests/test_recoverable.py index 71ada93a..8fdbe019 100644 --- a/tests/test_recoverable.py +++ b/tests/test_recoverable.py @@ -148,6 +148,21 @@ def test_used_reset_token(client, get_message): 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') From 398f5c920ba4b3fad543e2acbf02058fde02db30 Mon Sep 17 00:00:00 2001 From: Jon Banafato Date: Tue, 23 Jun 2015 13:23:07 -0400 Subject: [PATCH 24/24] Restrict bcrypt to <2.0.0 As of 2.0.0, passlib no longer correctly identifies bcrypt as bcrypt (instead, it mistakenly applies pybcrypt logic to bcrypt). This results in all Python 3 logic involving bcrypt failing. As a hotfix, we should require users to be on a version of bcrypt that passlib can handle a fix can be pushed into passlib. --- docs/configuration.rst | 5 ++++- requirements-dev.txt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 83627d49..3ac4a921 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -23,7 +23,10 @@ Core passwords. Recommended values for production systems are ``bcrypt``, ``sha512_crypt``, or ``pbkdf2_sha512``. - Defaults to ``plaintext``. + Defaults to ``plaintext``. Note: + ``bcrypt>=2.0.0`` is not currently + supported. If ``bcrypt`` is preferred, + please use ``bcrypt<2.0``. ``SECURITY_PASSWORD_SALT`` Specifies the HMAC salt. This is only used if the password hash type is set to something other than plain text. diff --git a/requirements-dev.txt b/requirements-dev.txt index 5a20b643..3027ffb3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ Flask-SQLAlchemy>=1.0 -bcrypt>=1.0.2 +bcrypt>=1.0.2,<2.0.0 flask-mongoengine>=0.7.0 flask-peewee>=0.6.5 pymongo==2.8