diff --git a/.gitignore b/.gitignore index 4d3ab55..b04ba2f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,13 @@ env/ *.db *cache* + +# vim +[._]*.s[a-w][a-z] +[._]s[a-w][a-z] +*.un~ +Session.vim +.netrwhist +*~ + +.eggs/README.txt 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/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/docs/configuration.rst b/docs/configuration.rst index 5227e12..6cc8bc1 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. @@ -31,12 +34,16 @@ 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 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`` @@ -159,7 +166,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/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/__init__.py b/flask_security/__init__.py index 77e8054..0c3c513 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. @@ -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' diff --git a/flask_security/changeable.py b/flask_security/changeable.py index 6918cc0..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 @@ -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/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..b1c0cae 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 @@ -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,17 +193,17 @@ 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 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 +218,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 +258,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'), @@ -272,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(): @@ -380,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. @@ -398,7 +403,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 +430,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__)) diff --git a/flask_security/datastore.py b/flask_security/datastore.py index aca5d50..44193a0 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. @@ -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 diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 34c3f7d..ecb8cc8 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 @@ -91,9 +91,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): @@ -113,7 +116,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 @@ -138,11 +144,18 @@ 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 + if _security._unauthorized_callback: + return _security._unauthorized_callback() + else: + return _get_unauthorized_response(headers=h) return decorated_view return wrapper @@ -167,7 +180,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 @@ -193,7 +209,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/flask_security/forms.py b/flask_security/forms.py index e3f6a3b..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 @@ -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/flask_security/passwordless.py b/flask_security/passwordless.py index dd6465c..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 @@ -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..328ced2 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 @@ -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, \ @@ -35,8 +36,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): @@ -54,7 +54,8 @@ def generate_reset_password_token(user): :param user: The user to work with """ - data = [str(user.id), md5(user.password)] + password_hash = md5(user.password) if user.password else None + data = [str(user.id), password_hash] return _security.reset_serializer.dumps(data) @@ -62,11 +63,19 @@ def reset_password_token_status(token): """Returns the expired status, invalid status, and user of a password reset token. For example:: - expired, invalid, user = reset_password_token_status('...') + expired, invalid, user, data = reset_password_token_status('...') :param token: The password reset token """ - return get_token_status(token, 'reset', 'RESET_PASSWORD') + expired, invalid, user, data = get_token_status(token, 'reset', 'RESET_PASSWORD', + return_data=True) + if not invalid: + if user.password: + password_hash = md5(user.password) + if not safe_str_cmp(password_hash, data[1]): + invalid = True + + return expired, invalid, user def update_password(user, password): diff --git a/flask_security/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..1ff01c8 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 @@ -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): @@ -124,7 +124,7 @@ class DeactivateUserCommand(_ToggleActiveCommand): class ActivateUserCommand(_ToggleActiveCommand): - """Deactive a user""" + """Activate a user""" @commit def run(self, user_identifier): 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..ccd5b0b 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 @@ -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 @@ -188,6 +188,14 @@ 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) @@ -333,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 @@ -359,7 +367,11 @@ def get_token_status(token, serializer, max_age=None): user = _datastore.find_user(id=data[0]) expired = expired and (user is not None) - return expired, invalid, user + + if return_data: + return expired, invalid, user, data + else: + return expired, invalid, user def get_identity_attributes(app=None): diff --git a/flask_security/views.py b/flask_security/views.py index 864c9e3..e9d8a57 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 @@ -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']) @@ -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.""" @@ -333,7 +334,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 +350,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 +363,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) diff --git a/requirements-dev.txt b/requirements-dev.txt index c231d3d..3027ffb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,8 @@ 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 pytest>=2.5.2 pytest-cache>=1.0 pytest-cov>=1.6 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_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'] 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_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')) diff --git a/tests/test_misc.py b/tests/test_misc.py index 96111aa..d931413 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, @@ -191,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 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..8fdbe01 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 @@ -117,6 +122,47 @@ def test_expired_reset_token(client, get_message): assert msg in response.data +def test_used_reset_token(client, get_message): + with capture_reset_password_requests() as requests: + client.post('/reset', data=dict(email='joe@lp.com'), follow_redirects=True) + + token = requests[0]['token'] + + # use the token + response = client.post('/reset/' + token, data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + + assert get_message('PASSWORD_RESET') in response.data + + logout(client) + + # attempt to use it a second time + response2 = client.post('/reset/' + token, data={ + 'password': 'otherpassword', + 'password_confirm': 'otherpassword' + }, follow_redirects=True) + + msg = get_message('INVALID_RESET_PASSWORD_TOKEN') + assert msg in response2.data + + +def test_reset_passwordless_user(client, get_message): + with capture_reset_password_requests() as requests: + client.post('/reset', data=dict(email='jess@lp.com'), follow_redirects=True) + + token = requests[0]['token'] + + # use the token + response = client.post('/reset/' + token, data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + + assert get_message('PASSWORD_RESET') in response.data + + @pytest.mark.settings(reset_url='/custom_reset') def test_custom_reset_url(client): response = client.get('/custom_reset') 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( 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