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 e098338..1aaa781 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 018d4ae..0a9bf69 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 fb2a6b6..d29149c 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -75,6 +75,7 @@ _default_config = { '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 a9c8084..cccbba7 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -100,7 +100,7 @@ class AddRoleCommand(_RoleCommand): 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 a9c8084..42051d3 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -124,7 +124,7 @@ class DeactivateUserCommand(_ToggleActiveCommand): 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 aca5d50..3d009ad 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -195,7 +195,7 @@ class SQLAlchemyUserDatastore(SQLAlchemyDatastore, UserDatastore): 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 86bb24a..b1996d8 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 864c9e3..7ec0cac 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -26,7 +26,7 @@ from .changeable import change_user_password 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 b1996d8..3ec962c 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 4d3ab55..a9701f2 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 018d4ae..b6b994f 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 86bb24a..0419ba0 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 1e08662..7017754 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 3363fc5..82025ae 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -137,11 +137,15 @@ def auth_required(*auth_methods): 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 b91c5e4..e884ab5 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 fb2a6b6..2d19d25 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 @@ class Security(object): 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 @@ class Security(object): 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 77e8054..f4c0cd1 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 .forms import ForgotPasswordForm, LoginForm, RegisterForm, \ 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 864c9e3..0f6d57c 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 1bb64d2..cec88c2 100644 --- a/tests/test_context_processors.py +++ b/tests/test_context_processors.py @@ -81,6 +81,8 @@ def test_context_processors(client, app): 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 53dcd68..902f8a0 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 6918cc0..c79f84e 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 dd6465c..387b7f2 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 eca5030..1bb6878 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 e63d2b2..4020226 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 1a06919..51116c4 100644 --- a/tests/test_confirmable.py +++ b/tests/test_confirmable.py @@ -10,6 +10,8 @@ import time 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 eddf479..1021412 100644 --- a/tests/test_passwordless.py +++ b/tests/test_passwordless.py @@ -10,8 +10,10 @@ import time 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 de278e1..8b91ece 100644 --- a/tests/test_recoverable.py +++ b/tests/test_recoverable.py @@ -10,8 +10,10 @@ import time 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 test_recoverable_flag(app, client, get_message): @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 9510c5b..6f7d476 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 a81942e..560f3d8 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 e3f6a3b..d5f15b9 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -13,7 +13,7 @@ import inspect 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 @@ class Form(BaseForm): 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 @@ class PasswordlessLoginForm(Form, UserEmailFormMixin): 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 96111aa..30b0fd0 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -11,7 +11,8 @@ import pytest 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 77e8054..647d1f0 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 c79f84e..c968f2e 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 8d19ae8..aed2d23 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 fb2a6b6..286d106 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 aca5d50..c66a63e 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 3363fc5..a928e04 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 collections import namedtuple 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 d5f15b9..62bbfda 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 387b7f2..2d375d8 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 1bb6878..12ea264 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 e4a7e78..781afed 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 a9c8084..27ec3ef 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 @@ except ImportError: 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 532bba9..b5b558e 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 86bb24a..fa0b17d 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 contextlib import contextmanager 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 0f6d57c..8acb5fd 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 c556c8f..83627d4 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 188c0cb..02761de 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 @@ class _SecurityState(object): 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 da95e02..356b569 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -90,9 +90,12 @@ def http_auth_required(realm): 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 auth_required(*auth_methods): 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 roles_required(*roles): 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 roles_accepted(*roles): 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 30b0fd0..d931413 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 c231d3d..5a20b64 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 356b569..6a011fb 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -154,7 +154,7 @@ def auth_required(*auth_methods): 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 12ea264..491a940 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 491a940..97fa883 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 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): 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 97fa883..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, \ @@ -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 8b91ece..71ada93 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 a9701f2..b04ba2f 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 71ada93..8fdbe01 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 83627d4..3ac4a92 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 5a20b64..3027ffb 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