diff --git a/docs/source/api.rst b/docs/source/api.rst index cbea41ec..735813c2 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -114,6 +114,7 @@ UserManager() login_manager = LoginManager(), password_crypt_context = None, send_email_function = emails.send_email, + make_safe_url_function = views.make_safe_url, token_manager = tokens.TokenManager(), ) diff --git a/docs/source/authorization.rst b/docs/source/authorization.rst index f191b22f..5a2af9a4 100644 --- a/docs/source/authorization.rst +++ b/docs/source/authorization.rst @@ -41,7 +41,7 @@ In the example below the current user is required to have the 'admin' role:: Note: Comparison of role names is case sensitive, so 'Member' will NOT match 'member'. Multiple string arguments -- the AND operation -~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The @roles_required decorator accepts multiple strings if the current_user is required to have **ALL** of these roles. @@ -54,7 +54,7 @@ In the example below the current user is required to have the **ALL** of these r Multiple string arguments represent the 'AND' operation. Array arguments -- the OR operation -~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The @roles_required decorator accepts an array (or a tuple) of roles. @@ -65,7 +65,7 @@ In the example below the current user is required to have **One or more** of the # Array arguments require at least ONE of these roles. AND/OR operations -~~~~~~~~ +~~~~~~~~~~~~~~~~~ The potentially confusing syntax described above allows us to construct complex AND/OR operations. @@ -83,7 +83,7 @@ Note: The nesting level only goes as deep as this example shows. Required Tables --------------- +--------------- For @login_required only the User model is required diff --git a/docs/source/basic_app.rst b/docs/source/basic_app.rst index b89eb334..bf6980a1 100644 --- a/docs/source/basic_app.rst +++ b/docs/source/basic_app.rst @@ -61,7 +61,7 @@ Highlighted lines shows the lines added to a basic Flask application. .. literalinclude:: includes/basic_app.py :language: python :linenos: - :emphasize-lines: 5, 39-55, 60-62, 79 + :emphasize-lines: 5, 39-55, 59-61, 78 Run the Basic App diff --git a/docs/source/data_models.rst b/docs/source/data_models.rst index 27de12a1..81f052ca 100644 --- a/docs/source/data_models.rst +++ b/docs/source/data_models.rst @@ -27,7 +27,6 @@ If you'd like to store all user information in one DataModel, use the following: # User Authentication information username = db.Column(db.String(50), nullable=False, unique=True) password = db.Column(db.String(255), nullable=False, default='') - reset_password_token = db.Column(db.String(100), nullable=False, default='') # User Email information email = db.Column(db.String(255), nullable=False, unique=True) @@ -76,7 +75,6 @@ If you'd like to store User Authentication information separate from User inform # User authentication information username = db.Column(db.String(50), nullable=False, unique=True) password = db.Column(db.String(255), nullable=False, default='') - reset_password_token = db.Column(db.String(100), nullable=False, default='') # Relationships user = db.relationship('User', uselist=False, foreign_keys=user_id) diff --git a/docs/source/flask_user_starter_app.rst b/docs/source/flask_user_starter_app.rst index a58bdffa..79e938d5 100644 --- a/docs/source/flask_user_starter_app.rst +++ b/docs/source/flask_user_starter_app.rst @@ -14,7 +14,7 @@ Files of interest: * app/startup/init_app.py * app/models/user.py -* app/templates/flask_user/*.html +* app/templates/flask_user/\*.html * app/templates/users/user_profile_page.html Up Next diff --git a/docs/source/index.rst b/docs/source/index.rst index 5a180796..d8396972 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,5 +1,5 @@ Flask-User |release| -========== +==================== .. image:: https://img.shields.io/pypi/v/Flask-User.svg :target: https://pypi.python.org/pypi/Flask-User @@ -112,6 +112,7 @@ Documentation Revision History ---------------- +* v0.6.11 Added make_safe_url() to prevent cross-domain redirections. * v0.6.10 Added Spanish translation. * v0.6.9 Added support for Flask-Login v0.4+. Replaced pycrypto with pycryptodome. diff --git a/docs/source/internationalization.rst b/docs/source/internationalization.rst index 2ccd684a..9bf20cfb 100644 --- a/docs/source/internationalization.rst +++ b/docs/source/internationalization.rst @@ -22,7 +22,7 @@ Flask-User ships with the following languages: REQUIRED: Installing Flask-Babel --------- +-------------------------------- Flask-User relies on the Flask-Babel package to translate the account management forms. Without Flask-Babel installed, these forms WILL NOT BE translated. @@ -34,7 +34,7 @@ Install Flask-Babel with REQUIRED: Initializing Flask-Babel --------- +---------------------------------- Flask-Babel must be initialized just after the Flask application has been initialized and after the application configuration has been read: @@ -211,7 +211,7 @@ Point your browser to your app and your translated messages should appear. Troubleshooting --------- +--------------- If the code looks right, but the account management forms are not being translated: * Check to see if the 'Flask-Babel' package has been installed (try using ``pip freeze``). diff --git a/docs/source/limitations.rst b/docs/source/limitations.rst index af362b31..8c7420a2 100644 --- a/docs/source/limitations.rst +++ b/docs/source/limitations.rst @@ -51,9 +51,9 @@ Required Data model field names: :: # User authentication information + User.id User.username or UserAuth.username User.password or UserAuth.password - User.reset_password_token or UserAuth.reset_password_token UserAuth.user_id # User email information diff --git a/docs/source/roles_required_app.rst b/docs/source/roles_required_app.rst index 1068efc5..bb3ff9e8 100644 --- a/docs/source/roles_required_app.rst +++ b/docs/source/roles_required_app.rst @@ -64,7 +64,7 @@ Highlighted lines shows the lines added to the Basic App. .. literalinclude:: includes/roles_required_app.py :language: python :linenos: - :emphasize-lines: 6, 64-66, 71-80, 89-96, 108, 123, 127-140 + :emphasize-lines: 6, 63-76, 85-92, 125 Run the Roles Required App -------------------------- diff --git a/example_apps/basic_app.py b/example_apps/basic_app.py index fa2beb54..ff56e210 100644 --- a/example_apps/basic_app.py +++ b/example_apps/basic_app.py @@ -43,7 +43,6 @@ class User(db.Model, UserMixin): # User authentication information username = db.Column(db.String(50), nullable=False, unique=True) password = db.Column(db.String(255), nullable=False, server_default='') - reset_password_token = db.Column(db.String(100), nullable=False, server_default='') # User email information email = db.Column(db.String(255), nullable=False, unique=True) diff --git a/example_apps/invite_app.py b/example_apps/invite_app.py index 6ec388e0..cb34cf1a 100644 --- a/example_apps/invite_app.py +++ b/example_apps/invite_app.py @@ -60,7 +60,6 @@ class User(db.Model, UserMixin): # User authentication information username = db.Column(db.String(50), nullable=True, unique=True) password = db.Column(db.String(255), nullable=False, server_default='') - reset_password_token = db.Column(db.String(100), nullable=False, server_default='') # User email information email = db.Column(db.String(255), nullable=False, unique=True) diff --git a/example_apps/multi_email_app.py b/example_apps/multi_email_app.py index 7e220343..c839c582 100644 --- a/example_apps/multi_email_app.py +++ b/example_apps/multi_email_app.py @@ -44,7 +44,6 @@ class User(db.Model, UserMixin): # User authentication information username = db.Column(db.String(50), nullable=False, unique=True) password = db.Column(db.String(255), nullable=False, server_default='') - reset_password_token = db.Column(db.String(100), nullable=False, server_default='') # User information active = db.Column('is_active', db.Boolean(), nullable=False, server_default='0') diff --git a/example_apps/roles_required_app.py b/example_apps/roles_required_app.py index c6528ba0..cb6cd4a6 100644 --- a/example_apps/roles_required_app.py +++ b/example_apps/roles_required_app.py @@ -50,7 +50,6 @@ class User(db.Model, UserMixin): # User authentication information username = db.Column(db.String(50), nullable=False, unique=True) password = db.Column(db.String(255), nullable=False, server_default='') - reset_password_token = db.Column(db.String(100), nullable=False, server_default='') # User email information email = db.Column(db.String(255), nullable=False, unique=True) diff --git a/example_apps/test_app.py b/example_apps/test_app.py index 3d2c268e..07839c0d 100644 --- a/example_apps/test_app.py +++ b/example_apps/test_app.py @@ -61,7 +61,6 @@ class User(db.Model, UserMixin): # User authentication information username = db.Column(db.String(50), nullable=False, unique=True) password = db.Column(db.String(255), nullable=False, server_default='') - reset_password_token = db.Column(db.String(100), nullable=False, server_default='') # User email information email = db.Column(db.String(255), nullable=False, unique=True) diff --git a/example_apps/user_auth_app.py b/example_apps/user_auth_app.py index 10f4d589..d23d4fab 100644 --- a/example_apps/user_auth_app.py +++ b/example_apps/user_auth_app.py @@ -69,7 +69,6 @@ class UserAuth(db.Model): # User authentication information username = db.Column(db.String(50), nullable=False, unique=True) password = db.Column(db.String(255), nullable=False, server_default='') - reset_password_token = db.Column(db.String(100), nullable=False, server_default='') # Relationships user = db.relationship('User', uselist=False) diff --git a/example_apps/user_profile_app.py b/example_apps/user_profile_app.py deleted file mode 100644 index 7ca09bf1..00000000 --- a/example_apps/user_profile_app.py +++ /dev/null @@ -1,155 +0,0 @@ -import os -from flask import Flask, render_template_string, request -from flask_mail import Mail -from flask_sqlalchemy import SQLAlchemy -from flask_user import login_required, SQLAlchemyAdapter, UserManager, UserMixin -from flask_user import roles_required - - -# Use a Class-based config to avoid needing a 2nd file -# os.getenv() enables configuration through OS environment variables -class ConfigClass(object): - # Flask settings - SECRET_KEY = os.getenv('SECRET_KEY', 'THIS IS AN INSECURE SECRET') - SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', 'sqlite:///single_file_app.sqlite') - CSRF_ENABLED = True - - # Flask-Mail settings - MAIL_USERNAME = os.getenv('MAIL_USERNAME', 'email@example.com') - MAIL_PASSWORD = os.getenv('MAIL_PASSWORD', 'password') - MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', '"MyApp" ') - MAIL_SERVER = os.getenv('MAIL_SERVER', 'smtp.gmail.com') - MAIL_PORT = int(os.getenv('MAIL_PORT', '465')) - MAIL_USE_SSL = int(os.getenv('MAIL_USE_SSL', True)) - - # Flask-User settings - USER_APP_NAME = "AppName" # Used by email templates - - -def create_app(test_config=None): # For automated tests - # Setup Flask and read config from ConfigClass defined above - app = Flask(__name__) - app.config.from_object(__name__+'.ConfigClass') - - # Load local_settings.py if file exists # For automated tests - try: app.config.from_object('local_settings') - except: pass - - # Load optional test_config # For automated tests - if test_config: - app.config.update(test_config) - - # Initialize Flask extensions - mail = Mail(app) # Initialize Flask-Mail - db = SQLAlchemy(app) # Initialize Flask-SQLAlchemy - - # Define User model. Make sure to add flask_user UserMixin!! - class User(db.Model, UserMixin): - id = db.Column(db.Integer, primary_key=True) - user_profile_id = db.Column(db.Integer(), db.ForeignKey('user_profile.id', ondelete='CASCADE')) - - # User authentication information - username = db.Column(db.String(50), nullable=False, unique=True) - password = db.Column(db.String(255), nullable=False, server_default='') - reset_password_token = db.Column(db.String(100), nullable=False, server_default='') - active = db.Column('is_active', db.Boolean(), nullable=False, server_default='0') - - # User email information - email = db.Column(db.String(255), nullable=False, unique=True) - confirmed_at = db.Column(db.DateTime()) - - # Relationships - user_profile = db.relationship('UserProfile', uselist=False, foreign_keys=[user_profile_id]) - - class UserProfile(db.Model): - id = db.Column(db.Integer, primary_key=True) - - # User information - first_name = db.Column(db.String(50), nullable=False, default='') - last_name = db.Column(db.String(50), nullable=False, default='') - - # Relationships - roles = db.relationship('Role', secondary='user_roles', - backref=db.backref('users', lazy='dynamic')) - - # Define Role model - class Role(db.Model): - id = db.Column(db.Integer(), primary_key=True) - name = db.Column(db.String(50), unique=True) - - # Define UserRoles model - class UserRoles(db.Model): - id = db.Column(db.Integer(), primary_key=True) - user_profile_id = db.Column(db.Integer(), db.ForeignKey('user_profile.id', ondelete='CASCADE')) - role_id = db.Column(db.Integer(), db.ForeignKey('role.id', ondelete='CASCADE')) - - # Reset all the database tables - db.create_all() - - # Setup Flask-User - db_adapter = SQLAlchemyAdapter(db, User, UserProfileClass=UserProfile) - user_manager = UserManager(db_adapter, app) - - # Create 'user007' user with 'secret' and 'agent' roles - if not User.query.filter(User.username=='user007').first(): - user_profile1 = UserProfile(first_name='James', last_name='Bond') - db.session.add(user_profile1) - user1 = User(user_profile=user_profile1, username='user007', - email='user007@example.com', password=user_manager.hash_password('Password1'), - active=True) - db.session.add(user1) - user_profile1.roles.append(Role(name='secret')) - user_profile1.roles.append(Role(name='agent')) - db.session.commit() - - # The Home page is accessible to anyone - @app.route('/') - def home_page(): - return render_template_string(""" - {% extends "base.html" %} - {% block content %} -

Home page

-

This page can be accessed by anyone.


-

Home page (anyone)

-

Members page (login required)

-

Special page (login with username 'user007' and password 'Password1')

- {% endblock %} - """) - - # The Members page is only accessible to authenticated users - @app.route('/members') - @login_required # Use of @login_required decorator - def members_page(): - return render_template_string(""" - {% extends "base.html" %} - {% block content %} -

Members page

-

This page can only be accessed by authenticated users.


-

Home page (anyone)

-

Members page (login required)

-

Special page (login with username 'user007' and password 'Password1')

- {% endblock %} - """) - - # The Special page requires a user with 'special' and 'sauce' roles or with 'special' and 'agent' roles. - @app.route('/special') - @roles_required('secret', ['sauce', 'agent']) # Use of @roles_required decorator - def special_page(): - return render_template_string(""" - {% extends "base.html" %} - {% block content %} -

Special Page

-

This page can only be accessed by user007.


-

Home page (anyone)

-

Members page (login required)

-

Special page (login with username 'user007' and password 'Password1')

- {% endblock %} - """) - - return app - - -# Start development web server -if __name__=='__main__': - app = create_app() - app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/fabfile.py b/fabfile.py index a07a9ea4..7bc10023 100644 --- a/fabfile.py +++ b/fabfile.py @@ -43,5 +43,6 @@ def rebuild_docs(): @task def upload_to_pypi(): + local('rm dist/*.tar.gz') local('python setup.py sdist') local('twine upload dist/*') \ No newline at end of file diff --git a/flask_user/__init__.py b/flask_user/__init__.py index 6996a99d..fffcfe75 100644 --- a/flask_user/__init__.py +++ b/flask_user/__init__.py @@ -28,7 +28,7 @@ from .signals import * -__version__ = '0.6.10' +__version__ = '0.6.11' def _call_or_get(function_or_property): @@ -89,6 +89,7 @@ def init_app(self, app, db_adapter=None, login_manager=LoginManager(), password_crypt_context=None, send_email_function = emails.send_email, + make_safe_url_function = views.make_safe_url, token_manager=tokens.TokenManager(), legacy_check_password_hash=None ): @@ -132,6 +133,7 @@ def init_app(self, app, db_adapter=None, self.token_manager = token_manager self.password_crypt_context = password_crypt_context self.send_email_function = send_email_function + self.make_safe_url_function = make_safe_url_function self.legacy_check_password_hash = legacy_check_password_hash """ Initialize app.user_manager.""" diff --git a/flask_user/forms.py b/flask_user/forms.py index 3f04cc5b..4f0f3212 100644 --- a/flask_user/forms.py +++ b/flask_user/forms.py @@ -334,7 +334,7 @@ def validate(self): class InviteForm(FlaskForm): email = StringField(_('Email'), validators=[ - validators.Required(_('Email is required')), + validators.DataRequired(_('Email is required')), validators.Email(_('Invalid Email'))]) next = HiddenField() submit = SubmitField(_('Invite!')) diff --git a/flask_user/tests/test_invalid_forms.py b/flask_user/tests/test_invalid_forms.py index 0aa9f166..26e8b31a 100644 --- a/flask_user/tests/test_invalid_forms.py +++ b/flask_user/tests/test_invalid_forms.py @@ -375,8 +375,7 @@ def test_invalid_reset_password(client): # Set default values new_password = 'Password5' # Simulate a valid forgot password form - user1.reset_password_token = um.generate_token(user1.id) - token = user1.reset_password_token + token = um.generate_token(user1.id) # Test invalid token url = url_for('user.reset_password', token='InvalidToken') diff --git a/flask_user/tests/test_valid_forms.py b/flask_user/tests/test_valid_forms.py index d50764b0..bf19a067 100644 --- a/flask_user/tests/test_valid_forms.py +++ b/flask_user/tests/test_valid_forms.py @@ -282,8 +282,7 @@ def check_valid_reset_password_page(um, client): print("test_valid_reset_password_page") # Simulate a valid forgot password form - valid_user.reset_password_token = um.generate_token(valid_user.id) - token = valid_user.reset_password_token + token = um.generate_token(valid_user.id) # Define defaults password = 'Password1' diff --git a/flask_user/tests/tst_app.py b/flask_user/tests/tst_app.py index fb3762ec..3c9d1036 100644 --- a/flask_user/tests/tst_app.py +++ b/flask_user/tests/tst_app.py @@ -19,7 +19,7 @@ class User(db.Model, UserMixin): # User authentication information username = db.Column(db.String(50), nullable=True, unique=True) password = db.Column(db.String(255), nullable=False, server_default='') - reset_password_token = db.Column(db.String(100), nullable=False, server_default='') + # reset_password_token = db.Column(db.String(100), nullable=False, server_default='') # User email information email = db.Column(db.String(255), nullable=True, unique=True) diff --git a/flask_user/views.py b/flask_user/views.py index 18001a4c..54c854a7 100644 --- a/flask_user/views.py +++ b/flask_user/views.py @@ -7,15 +7,22 @@ from datetime import datetime from flask import current_app, flash, redirect, request, url_for from flask_login import current_user, login_user, logout_user -try: # Handle Python 2.x and Python 3.x - from urllib.parse import quote # Python 3.x -except ImportError: - from urllib import quote # Python 2.x from .decorators import confirm_email_required, login_required from . import emails from . import signals from .translations import gettext as _ +# Python version specific imports +from sys import version_info as py_version +is_py2 = (py_version[0] == 2) #: Python 2.x? +is_py3 = (py_version[0] == 3) #: Python 3.x? +if is_py2: + from urlparse import urlsplit, urlunsplit + from urllib import quote, unquote +if is_py3: + from urllib.parse import urlsplit, urlunsplit + from urllib.parse import quote, unquote + def _call_or_get(function_or_property): return function_or_property() if callable(function_or_property) else function_or_property @@ -70,11 +77,11 @@ def confirm_email(token): flash(_('Your email has been confirmed.'), 'success') # Auto-login after confirm or redirect to login page - next = request.args.get('next', _endpoint_url(user_manager.after_confirm_endpoint)) + safe_next = _get_safe_next_param('next', user_manager.after_confirm_endpoint) if user_manager.auto_login_after_confirm: - return _do_login_user(user, next) # auto-login + return _do_login_user(user, safe_next) # auto-login else: - return redirect(url_for('user.login')+'?next='+next) # redirect to login page + return redirect(url_for('user.login')+'?next='+quote(safe_next)) # redirect to login page @login_required @@ -86,7 +93,8 @@ def change_password(): # Initialize form form = user_manager.change_password_form(request.form) - form.next.data = request.args.get('next', _endpoint_url(user_manager.after_change_password_endpoint)) # Place ?next query param in next form field + safe_next = _get_safe_next_param('next', user_manager.after_change_password_endpoint) + form.next.data = safe_next # Process valid POST if request.method=='POST' and form.validate(): @@ -107,7 +115,8 @@ def change_password(): flash(_('Your password has been changed successfully.'), 'success') # Redirect to 'next' URL - return redirect(form.next.data) + safe_next = user_manager.make_safe_url_function(form.next.data) + return redirect(safe_next) # Process GET or invalid POST return render(user_manager.change_password_template, form=form) @@ -121,7 +130,8 @@ def change_username(): # Initialize form form = user_manager.change_username_form(request.form) - form.next.data = request.args.get('next', _endpoint_url(user_manager.after_change_username_endpoint)) # Place ?next query param in next form field + safe_next = _get_safe_next_param('next', user_manager.after_change_username_endpoint) + form.next.data = safe_next # Process valid POST if request.method=='POST' and form.validate(): @@ -143,7 +153,8 @@ def change_username(): flash(_("Your username has been changed to '%(username)s'.", username=new_username), 'success') # Redirect to 'next' URL - return redirect(form.next.data) + safe_next = user_manager.make_safe_url_function(form.next.data) + return redirect(safe_next) # Process GET or invalid POST return render(user_manager.change_username_template, form=form) @@ -221,19 +232,19 @@ def login(): user_manager = current_app.user_manager db_adapter = user_manager.db_adapter - next = request.args.get('next', _endpoint_url(user_manager.after_login_endpoint)) - reg_next = request.args.get('reg_next', _endpoint_url(user_manager.after_register_endpoint)) + safe_next = _get_safe_next_param('next', user_manager.after_login_endpoint) + safe_reg_next = _get_safe_next_param('reg_next', user_manager.after_register_endpoint) # Immediately redirect already logged in users if _call_or_get(current_user.is_authenticated) and user_manager.auto_login_at_login: - return redirect(next) + return redirect(safe_next) # Initialize form login_form = user_manager.login_form(request.form) # for login.html register_form = user_manager.register_form() # for login_or_register.html if request.method!='POST': - login_form.next.data = register_form.next.data = next - login_form.reg_next.data = register_form.reg_next.data = reg_next + login_form.next.data = register_form.next.data = safe_next + login_form.reg_next.data = register_form.reg_next.data = safe_reg_next # Process valid POST if request.method=='POST' and login_form.validate(): @@ -259,7 +270,8 @@ def login(): if user: # Log user in - return _do_login_user(user, login_form.next.data, login_form.remember_me.data) + safe_next = user_manager.make_safe_url_function(login_form.next.data) + return _do_login_user(user, safe_next, login_form.remember_me.data) # Process GET or invalid POST return render(user_manager.login_template, @@ -281,8 +293,8 @@ def logout(): flash(_('You have signed out successfully.'), 'success') # Redirect to logout_next endpoint or '/' - next = request.args.get('next', _endpoint_url(user_manager.after_logout_endpoint)) # Get 'next' query param - return redirect(next) + safe_next = _get_safe_next_param('next', user_manager.after_logout_endpoint) + return redirect(safe_next) @login_required @@ -314,8 +326,8 @@ def register(): user_manager = current_app.user_manager db_adapter = user_manager.db_adapter - next = request.args.get('next', _endpoint_url(user_manager.after_login_endpoint)) - reg_next = request.args.get('reg_next', _endpoint_url(user_manager.after_register_endpoint)) + safe_next = _get_safe_next_param('next', user_manager.after_login_endpoint) + safe_reg_next = _get_safe_next_param('reg_next', user_manager.after_register_endpoint) # Initialize form login_form = user_manager.login_form() # for login_or_register.html @@ -339,8 +351,8 @@ def register(): return redirect(url_for('user.login')) if request.method!='POST': - login_form.next.data = register_form.next.data = next - login_form.reg_next.data = register_form.reg_next.data = reg_next + login_form.next.data = register_form.next.data = safe_next + login_form.reg_next.data = register_form.reg_next.data = safe_reg_next if user_invite: register_form.email.data = user_invite.email @@ -447,15 +459,18 @@ def register(): # Redirect if USER_ENABLE_CONFIRM_EMAIL is set if user_manager.enable_confirm_email and require_email_confirmation: - next = request.args.get('next', _endpoint_url(user_manager.after_register_endpoint)) - return redirect(next) + safe_reg_next = user_manager.make_safe_url_function(register_form.reg_next.data) + return redirect(safe_reg_next) # Auto-login after register or redirect to login page - next = request.args.get('next', _endpoint_url(user_manager.after_confirm_endpoint)) + if 'reg_next' in request.args: + safe_reg_next = user_manager.make_safe_url_function(register_form.reg_next.data) + else: + safe_reg_next = _endpoint_url(user_manager.after_confirm_endpoint) if user_manager.auto_login_after_register: - return _do_login_user(user, reg_next) # auto-login + return _do_login_user(user, safe_reg_next) # auto-login else: - return redirect(url_for('user.login')+'?next='+reg_next) # redirect to login page + return redirect(url_for('user.login')+'?next='+quote(safe_reg_next)) # redirect to login page # Process GET or invalid POST return render(user_manager.register_template, @@ -469,9 +484,6 @@ def invite(): user_manager = current_app.user_manager db_adapter = user_manager.db_adapter - next = request.args.get('next', - _endpoint_url(user_manager.after_invite_endpoint)) - invite_form = user_manager.invite_form(request.form) if request.method=='POST' and invite_form.validate(): @@ -520,7 +532,8 @@ def invite(): form=invite_form) flash(_('Invitation has been sent.'), 'success') - return redirect(next) + safe_next = _get_safe_next_param('next', user_manager.after_invite_endpoint) + return redirect(safe_next) return render(user_manager.invite_template, form=invite_form) @@ -607,11 +620,11 @@ def reset_password(token): flash(_("Your password has been reset successfully."), 'success') # Auto-login after reset password or redirect to login page - next = request.args.get('next', _endpoint_url(user_manager.after_reset_password_endpoint)) + safe_next = _get_safe_next_param('next', user_manager.after_reset_password_endpoint) if user_manager.auto_login_after_reset_password: - return _do_login_user(user, next) # auto-login + return _do_login_user(user, safe_next) # auto-login else: - return redirect(url_for('user.login')+'?next='+next) # redirect to login page + return redirect(url_for('user.login')+'?next='+quote(safe_next)) # redirect to login page # Process GET or invalid POST return render(user_manager.reset_password_template, form=form) @@ -630,16 +643,14 @@ def unconfirmed(): def unauthenticated(): """ Prepare a Flash message and redirect to USER_UNAUTHENTICATED_ENDPOINT""" + user_manager = current_app.user_manager # Prepare Flash message url = request.url flash(_("You must be signed in to access '%(url)s'.", url=url), 'error') - # quote the fully qualified url - quoted_url = quote(url) - # Redirect to USER_UNAUTHENTICATED_ENDPOINT - user_manager = current_app.user_manager - return redirect(_endpoint_url(user_manager.unauthenticated_endpoint)+'?next='+ quoted_url) + safe_next = user_manager.make_safe_url_function(url) + return redirect(_endpoint_url(user_manager.unauthenticated_endpoint)+'?next='+quote(safe_next)) def unauthorized(): @@ -701,7 +712,7 @@ def _send_confirm_email(user, user_email): flash(_('A confirmation email has been sent to %(email)s with instructions to complete your registration.', email=email), 'success') -def _do_login_user(user, next, remember_me=False): +def _do_login_user(user, safe_next, remember_me=False): # User must have been authenticated if not user: return unauthenticated() @@ -729,8 +740,30 @@ def _do_login_user(user, next, remember_me=False): # Prepare one-time system message flash(_('You have signed in successfully.'), 'success') - # Redirect to 'next' URL - return redirect(next) + # Redirect to 'safe_next' URL + return redirect(safe_next) + + +# Turns an usafe absolute URL into a safe relative URL by removing the scheme and the hostname +# Example: make_safe_url('http://hostname/path1/path2?q1=v1&q2=v2#fragment') +# returns: '/path1/path2?q1=v1&q2=v2#fragment +def make_safe_url(url): + parts = urlsplit(url) + safe_url = parts.path+parts.query+parts.fragment + return safe_url + + +# 'next' and 'reg_next' query parameters contain quoted (URL-encoded) URLs +# that may contain unsafe hostnames. +# Return the query parameter as a safe, unquoted URL +def _get_safe_next_param(param_name, default_endpoint): + if param_name in request.args: + # return safe unquoted query parameter value + safe_next = current_app.user_manager.make_safe_url_function(unquote(request.args[param_name])) + else: + # return URL of default endpoint + safe_next = _endpoint_url(default_endpoint) + return safe_next def _endpoint_url(endpoint): diff --git a/requirements_dev.txt b/requirements_dev.txt index f2f42ab7..cb49313b 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -11,8 +11,11 @@ Flask-WTF==0.9.4 # Python packages passlib==1.6.5 bcrypt==2.0.0 -pycrypto==2.6.1 +pycryptodome==3.4.3 speaklater==1.3 # Development packages -pytest==2.6.4 +pytest==3.0.5 +Sphinx==1.5.1 +tox==2.5.0 +twine==1.8.1 diff --git a/setup.py b/setup.py index 6eb52856..5c8e919d 100644 --- a/setup.py +++ b/setup.py @@ -87,7 +87,7 @@ setup( name='Flask-User', - version='0.6.10', + version='0.6.11', url='http://github.com/lingthio/Flask-User', license='BSD License', author='Ling Thio', diff --git a/tox.ini b/tox.ini index eb9ff635..d1f91754 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] # Test on the following Python versions -envlist = py27, py33, py34 +envlist = py27, py33, py34, py35 +skip_missing_interpreters = True toxworkdir=../builds/flask_user/tox skipsdist=True